diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 57eea90..b961b81 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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" ] diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index c92386e..07d5b90 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -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 diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index a757eb2..e7afe8e 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -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}} diff --git a/.gitignore b/.gitignore index f97f6b8..fcda727 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ riderModule.iml /.vs/ GuildData/ Logs/ +compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6cfeac6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0@sha256:7d24e90a392e88eb56093e4eb325ff883ad609382a55d42f17fd557b997022ca AS build-env +WORKDIR /Octobot + +# Copy everything +COPY . ./ +# Load build argument with publish options +ARG PUBLISH_OPTIONS="-c Release" +# Build and publish a release +RUN dotnet publish ./TeamOctolings.Octobot $PUBLISH_OPTIONS -o out + +# Build runtime image +FROM mcr.microsoft.com/dotnet/runtime:9.0@sha256:1e5eb0ed94ca96a34a914456db80e48bd1bb7bc3e3c8eda5e2c3d89c153c3081 +WORKDIR /Octobot +COPY --from=build-env /Octobot/out . +ENTRYPOINT ["./TeamOctolings.Octobot"] diff --git a/TeamOctolings.Octobot/BuildInfo.cs b/TeamOctolings.Octobot/BuildInfo.cs index 4b9a09f..a91e7f3 100644 --- a/TeamOctolings.Octobot/BuildInfo.cs +++ b/TeamOctolings.Octobot/BuildInfo.cs @@ -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"; diff --git a/TeamOctolings.Octobot/Commands/AboutCommandGroup.cs b/TeamOctolings.Octobot/Commands/AboutCommandGroup.cs index dbb8b12..28caccf 100644 --- a/TeamOctolings.Octobot/Commands/AboutCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/AboutCommandGroup.cs @@ -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); } } diff --git a/TeamOctolings.Octobot/Commands/BanCommandGroup.cs b/TeamOctolings.Octobot/Commands/BanCommandGroup.cs index 69be80f..1d6b26c 100644 --- a/TeamOctolings.Octobot/Commands/BanCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/BanCommandGroup.cs @@ -62,7 +62,7 @@ public sealed class BanCommandGroup : CommandGroup /// /// /// 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. /// /// [Command("ban", "бан")] @@ -219,7 +219,7 @@ public sealed class BanCommandGroup : CommandGroup /// /// /// 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. /// /// /// diff --git a/TeamOctolings.Octobot/Commands/ClearCommandGroup.cs b/TeamOctolings.Octobot/Commands/ClearCommandGroup.cs index 7c1b516..7f29581 100644 --- a/TeamOctolings.Octobot/Commands/ClearCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/ClearCommandGroup.cs @@ -51,7 +51,7 @@ public sealed class ClearCommandGroup : CommandGroup /// The user whose messages will be cleared. /// /// A feedback sending result which may or may not have succeeded. A successful result does not mean that any messages - /// were cleared and vice-versa. + /// were cleared and vice versa. /// [Command("clear", "очистить")] [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] diff --git a/TeamOctolings.Octobot/Commands/Events/ErrorLoggingPostExecutionEvent.cs b/TeamOctolings.Octobot/Commands/Events/ErrorLoggingPostExecutionEvent.cs index 7409d3b..ff7339f 100644 --- a/TeamOctolings.Octobot/Commands/Events/ErrorLoggingPostExecutionEvent.cs +++ b/TeamOctolings.Octobot/Commands/Events/ErrorLoggingPostExecutionEvent.cs @@ -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) ); } diff --git a/TeamOctolings.Octobot/Commands/InfoCommandGroup.cs b/TeamOctolings.Octobot/Commands/InfoCommandGroup.cs index d7798f3..f07b210 100644 --- a/TeamOctolings.Octobot/Commands/InfoCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/InfoCommandGroup.cs @@ -288,7 +288,7 @@ public sealed class InfoCommandGroup : CommandGroup return await ShowGuildInfoAsync(bot, guild, CancellationToken); } - private Task ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct) + private Task ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct = default) { var description = new StringBuilder().AppendLine($"## {guild.Name}"); diff --git a/TeamOctolings.Octobot/Commands/KickCommandGroup.cs b/TeamOctolings.Octobot/Commands/KickCommandGroup.cs index a8fea2a..3011375 100644 --- a/TeamOctolings.Octobot/Commands/KickCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/KickCommandGroup.cs @@ -57,7 +57,7 @@ public sealed class KickCommandGroup : CommandGroup /// /// /// 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. /// [Command("kick", "кик")] [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] diff --git a/TeamOctolings.Octobot/Commands/MuteCommandGroup.cs b/TeamOctolings.Octobot/Commands/MuteCommandGroup.cs index 282afe8..5dce0b6 100644 --- a/TeamOctolings.Octobot/Commands/MuteCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/MuteCommandGroup.cs @@ -59,7 +59,7 @@ public sealed class MuteCommandGroup : CommandGroup /// /// /// 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. /// /// [Command("mute", "мут")] @@ -170,7 +170,7 @@ public sealed class MuteCommandGroup : CommandGroup private async Task 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 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 { muteRole }; var memberData = data.GetOrCreateMemberData(target.ID); @@ -208,7 +208,7 @@ public sealed class MuteCommandGroup : CommandGroup private async Task 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 /// /// /// 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. /// /// /// diff --git a/TeamOctolings.Octobot/Commands/RemindCommandGroup.cs b/TeamOctolings.Octobot/Commands/RemindCommandGroup.cs index bf59d67..3188d27 100644 --- a/TeamOctolings.Octobot/Commands/RemindCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/RemindCommandGroup.cs @@ -78,7 +78,7 @@ public sealed class RemindCommandGroup : CommandGroup return await ListRemindersAsync(data.GetOrCreateMemberData(executorId), guildId, executor, bot, CancellationToken); } - private Task ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct) + private Task 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 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); diff --git a/TeamOctolings.Octobot/Commands/SettingsCommandGroup.cs b/TeamOctolings.Octobot/Commands/SettingsCommandGroup.cs index 0acaa88..15aa42b 100644 --- a/TeamOctolings.Octobot/Commands/SettingsCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/SettingsCommandGroup.cs @@ -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) { diff --git a/TeamOctolings.Octobot/Commands/ToolsCommandGroup.cs b/TeamOctolings.Octobot/Commands/ToolsCommandGroup.cs index b4c3488..2936392 100644 --- a/TeamOctolings.Octobot/Commands/ToolsCommandGroup.cs +++ b/TeamOctolings.Octobot/Commands/ToolsCommandGroup.cs @@ -90,7 +90,7 @@ public sealed class ToolsCommandGroup : CommandGroup } private Task 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 SendTimestampAsync(TimeSpan? offset, IUser executor, CancellationToken ct) + private Task 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 AnswerEightBallAsync(IUser bot, CancellationToken ct) + private Task AnswerEightBallAsync(IUser bot, CancellationToken ct = default) { var typeNumber = Random.Shared.Next(0, 4); var embedColor = typeNumber switch diff --git a/TeamOctolings.Octobot/Data/Options/BoolOption.cs b/TeamOctolings.Octobot/Data/Options/BoolOption.cs index 6a3c899..3b81abb 100644 --- a/TeamOctolings.Octobot/Data/Options/BoolOption.cs +++ b/TeamOctolings.Octobot/Data/Options/BoolOption.cs @@ -12,6 +12,16 @@ public sealed class BoolOption : GuildOption return Get(settings) ? Messages.Yes : Messages.No; } + public override Result ValueEquals(JsonNode settings, string value) + { + if (!TryParseBool(value, out var boolean)) + { + return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue); + } + + return Value(settings).Equals(boolean.ToString()); + } + public override Result Set(JsonNode settings, string from) { if (!TryParseBool(from, out var value)) diff --git a/TeamOctolings.Octobot/Data/Options/GuildOption.cs b/TeamOctolings.Octobot/Data/Options/GuildOption.cs index 5d9f1a2..ea9c30e 100644 --- a/TeamOctolings.Octobot/Data/Options/GuildOption.cs +++ b/TeamOctolings.Octobot/Data/Options/GuildOption.cs @@ -21,9 +21,19 @@ public class GuildOption : 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 ValueEquals(JsonNode settings, string value) + { + return Value(settings).Equals(value); } /// diff --git a/TeamOctolings.Octobot/Data/Options/IGuildOption.cs b/TeamOctolings.Octobot/Data/Options/IGuildOption.cs index a8c3e6e..9920281 100644 --- a/TeamOctolings.Octobot/Data/Options/IGuildOption.cs +++ b/TeamOctolings.Octobot/Data/Options/IGuildOption.cs @@ -7,6 +7,7 @@ public interface IGuildOption { string Name { get; } string Display(JsonNode settings); + Result ValueEquals(JsonNode settings, string value); Result Set(JsonNode settings, string from); Result Reset(JsonNode settings); } diff --git a/TeamOctolings.Octobot/Data/Options/LanguageOption.cs b/TeamOctolings.Octobot/Data/Options/LanguageOption.cs index 15ab6ff..f58e011 100644 --- a/TeamOctolings.Octobot/Data/Options/LanguageOption.cs +++ b/TeamOctolings.Octobot/Data/Options/LanguageOption.cs @@ -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 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() ?? "en"); + return settings[Name]?.GetValue() ?? "en"; } /// diff --git a/TeamOctolings.Octobot/Data/Options/TimeSpanOption.cs b/TeamOctolings.Octobot/Data/Options/TimeSpanOption.cs index 3501f09..7e21343 100644 --- a/TeamOctolings.Octobot/Data/Options/TimeSpanOption.cs +++ b/TeamOctolings.Octobot/Data/Options/TimeSpanOption.cs @@ -8,6 +8,16 @@ public sealed class TimeSpanOption : GuildOption { public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { } + public override Result ValueEquals(JsonNode settings, string value) + { + if (!TimeSpanParser.TryParse(value).IsDefined(out var span)) + { + return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue); + } + + return Value(settings).Equals(span.ToString()); + } + public override TimeSpan Get(JsonNode settings) { var property = settings[Name]; diff --git a/TeamOctolings.Octobot/Data/Reminder.cs b/TeamOctolings.Octobot/Data/Reminder.cs index c3936da..40f29e1 100644 --- a/TeamOctolings.Octobot/Data/Reminder.cs +++ b/TeamOctolings.Octobot/Data/Reminder.cs @@ -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; } } diff --git a/TeamOctolings.Octobot/Extensions/GuildScheduledEventExtensions.cs b/TeamOctolings.Octobot/Extensions/GuildScheduledEventExtensions.cs index b8eb2d1..7822d9b 100644 --- a/TeamOctolings.Octobot/Extensions/GuildScheduledEventExtensions.cs +++ b/TeamOctolings.Octobot/Extensions/GuildScheduledEventExtensions.cs @@ -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)); diff --git a/TeamOctolings.Octobot/Extensions/LoggerExtensions.cs b/TeamOctolings.Octobot/Extensions/LoggerExtensions.cs index 76fa386..fac4dda 100644 --- a/TeamOctolings.Octobot/Extensions/LoggerExtensions.cs +++ b/TeamOctolings.Octobot/Extensions/LoggerExtensions.cs @@ -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; } diff --git a/TeamOctolings.Octobot/Extensions/MarkdownExtensions.cs b/TeamOctolings.Octobot/Extensions/MarkdownExtensions.cs index 202cd37..30ddff5 100644 --- a/TeamOctolings.Octobot/Extensions/MarkdownExtensions.cs +++ b/TeamOctolings.Octobot/Extensions/MarkdownExtensions.cs @@ -13,4 +13,16 @@ public static class MarkdownExtensions { return $"- {text}"; } + + /// + /// Formats a string to use Markdown Quote formatting. + /// + /// The input text to format. + /// + /// A markdown-formatted quote string. + /// + public static string Quote(string text) + { + return $"> {text}"; + } } diff --git a/TeamOctolings.Octobot/Extensions/ResultExtensions.cs b/TeamOctolings.Octobot/Extensions/ResultExtensions.cs index d7ef7d7..6872d34 100644 --- a/TeamOctolings.Octobot/Extensions/ResultExtensions.cs +++ b/TeamOctolings.Octobot/Extensions/ResultExtensions.cs @@ -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; } diff --git a/TeamOctolings.Octobot/Messages.Designer.cs b/TeamOctolings.Octobot/Messages.Designer.cs index bbc1366..1a81e02 100644 --- a/TeamOctolings.Octobot/Messages.Designer.cs +++ b/TeamOctolings.Octobot/Messages.Designer.cs @@ -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); + } + } } } diff --git a/TeamOctolings.Octobot/Messages.resx b/TeamOctolings.Octobot/Messages.resx index 47e7d4f..e4107fb 100644 --- a/TeamOctolings.Octobot/Messages.resx +++ b/TeamOctolings.Octobot/Messages.resx @@ -399,8 +399,8 @@ Developers: - - Octobot's source code + + Open Website About {0} @@ -681,4 +681,7 @@ Moderator role + + The setting value is the same as the input value. + diff --git a/TeamOctolings.Octobot/Messages.ru.resx b/TeamOctolings.Octobot/Messages.ru.resx index 2eef257..d942cec 100644 --- a/TeamOctolings.Octobot/Messages.ru.resx +++ b/TeamOctolings.Octobot/Messages.ru.resx @@ -399,8 +399,8 @@ Разработчики: - - Исходный код Octobot + + Открыть веб-сайт О боте {0} @@ -681,4 +681,7 @@ Роль модератора + + Значение настройки такое же, как и вводное значение. + diff --git a/TeamOctolings.Octobot/Program.cs b/TeamOctolings.Octobot/Program.cs index d1d6220..8cdbdcf 100644 --- a/TeamOctolings.Octobot/Program.cs +++ b/TeamOctolings.Octobot/Program.cs @@ -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(); @@ -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( - options => - { - options.Intents |= GatewayIntents.MessageContents - | GatewayIntents.GuildMembers - | GatewayIntents.GuildPresences - | GatewayIntents.GuildScheduledEvents; - }); - services.Configure( - cSettings => - { - cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); - cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); - cSettings.SetAbsoluteExpiration(TimeSpan.FromDays(7)); - cSettings.SetSlidingExpiration(TimeSpan.FromDays(7)); - }); + services.Configure(options => + { + options.Intents |= GatewayIntents.MessageContents + | GatewayIntents.GuildMembers + | GatewayIntents.GuildPresences + | GatewayIntents.GuildScheduledEvents; + }); + services.Configure(cSettings => + { + cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); + cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); + cSettings.SetAbsoluteExpiration(TimeSpan.FromDays(7)); + cSettings.SetSlidingExpiration(TimeSpan.FromDays(7)); + }); services.AddTransient() // Init @@ -87,14 +83,13 @@ public sealed class Program .AddHostedService() .AddHostedService(); } - ).ConfigureLogging( - c => c.AddConsole() - .AddFile("Logs/Octobot-{Date}.log", - outputTemplate: "{Timestamp:o} [{Level:u4}] {Message} {NewLine}{Exception}") - .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning) - .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning) - .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning) - .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning) + ).ConfigureLogging(c => c.AddConsole() + .AddFile("Logs/Octobot-{Date}.log", + outputTemplate: "{Timestamp:o} [{Level:u4}] {Message} {NewLine}{Exception}") + .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning) + .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning) + .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning) + .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning) ); } } diff --git a/TeamOctolings.Octobot/Responders/GuildLoadedResponder.cs b/TeamOctolings.Octobot/Responders/GuildLoadedResponder.cs index cebb1ea..b24ef0b 100644 --- a/TeamOctolings.Octobot/Responders/GuildLoadedResponder.cs +++ b/TeamOctolings.Octobot/Responders/GuildLoadedResponder.cs @@ -94,7 +94,7 @@ public sealed class GuildLoadedResponder : IResponder GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, ct: ct); } - private async Task SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct) + private async Task SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct = default) { var channelResult = await _utility.GetEmergencyFeedbackChannel(guild, data, ct); if (!channelResult.IsDefined(out var channel)) @@ -120,6 +120,6 @@ public sealed class GuildLoadedResponder : IResponder ); return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed, - components: new[] { new ActionRowComponent(new[] { issuesButton }) }, ct: ct); + components: new[] { new ActionRowComponent([issuesButton]) }, ct: ct); } } diff --git a/TeamOctolings.Octobot/Responders/GuildMemberJoinedResponder.cs b/TeamOctolings.Octobot/Responders/GuildMemberJoinedResponder.cs index c1f1da0..ae9f174 100644 --- a/TeamOctolings.Octobot/Responders/GuildMemberJoinedResponder.cs +++ b/TeamOctolings.Octobot/Responders/GuildMemberJoinedResponder.cs @@ -81,7 +81,7 @@ public sealed class GuildMemberJoinedResponder : IResponder } private async Task 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)) { diff --git a/TeamOctolings.Octobot/Responders/GuildMemberLeftResponder.cs b/TeamOctolings.Octobot/Responders/GuildMemberLeftResponder.cs index 9774899..957a107 100644 --- a/TeamOctolings.Octobot/Responders/GuildMemberLeftResponder.cs +++ b/TeamOctolings.Octobot/Responders/GuildMemberLeftResponder.cs @@ -36,13 +36,9 @@ public sealed class GuildMemberLeftResponder : IResponder 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; } diff --git a/TeamOctolings.Octobot/Responders/MessageDeletedResponder.cs b/TeamOctolings.Octobot/Responders/MessageDeletedResponder.cs index 88a8de2..f0e3d22 100644 --- a/TeamOctolings.Octobot/Responders/MessageDeletedResponder.cs +++ b/TeamOctolings.Octobot/Responders/MessageDeletedResponder.cs @@ -66,10 +66,10 @@ public sealed class MessageDeletedResponder : IResponder return ResultExtensions.FromError(auditLogResult); } - var auditLog = auditLogPage.AuditLogEntries.Single(); - var deleterResult = Result.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) { diff --git a/TeamOctolings.Octobot/Responders/MessageEditedResponder.cs b/TeamOctolings.Octobot/Responders/MessageEditedResponder.cs index 2968562..e3d1c58 100644 --- a/TeamOctolings.Octobot/Responders/MessageEditedResponder.cs +++ b/TeamOctolings.Octobot/Responders/MessageEditedResponder.cs @@ -36,40 +36,29 @@ public sealed class MessageEditedResponder : IResponder public async Task 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( 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 // 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(); diff --git a/TeamOctolings.Octobot/Services/GuildDataService.cs b/TeamOctolings.Octobot/Services/GuildDataService.cs index 866ee08..88edb5f 100644 --- a/TeamOctolings.Octobot/Services/GuildDataService.cs +++ b/TeamOctolings.Octobot/Services/GuildDataService.cs @@ -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(); var datas = _datas.Values.ToArray(); @@ -44,7 +44,7 @@ public sealed class GuildDataService : BackgroundService return Task.WhenAll(tasks); } - private static async Task SerializeObjectSafelyAsync(T obj, string path, CancellationToken ct) + private static async Task SerializeObjectSafelyAsync(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? events = null; - try + else { - events = await JsonSerializer.DeserializeAsync>( - 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(); - 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(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 LoadMemberData(FileInfo dataFileInfo, string memberDataPath, bool loadTmp, + CancellationToken ct = default) + { + MemberData? data; + var temporaryPath = $"{dataFileInfo.FullName}.tmp"; + var usedInfo = loadTmp && File.Exists(temporaryPath) ? new FileInfo(temporaryPath) : dataFileInfo; + + var isTmp = usedInfo.Extension is ".tmp"; + try + { + await using var dataStream = usedInfo.OpenRead(); + data = await JsonSerializer.DeserializeAsync(dataStream, cancellationToken: ct); + if (isTmp) + { + usedInfo.CopyTo(usedInfo.FullName.Replace(".tmp", ""), true); + usedInfo.Delete(); + } + } + catch (Exception e) + { + if (isTmp) + { + _logger.LogWarning(e, + "Unable to load temporary member data file, deleting: {MemberDataPath}/{FileName}", memberDataPath, + usedInfo.Name); + usedInfo.Delete(); + return await LoadMemberData(dataFileInfo, memberDataPath, false, ct); + } + + _logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath, + usedInfo.Name); + return null; + } + + return data; + } + + private async Task?> LoadScheduledEvents(string scheduledEventsPath, + CancellationToken ct = default) + { + var tempScheduledEventsPath = $"{scheduledEventsPath}.tmp"; + + if (!File.Exists(scheduledEventsPath) && !File.Exists(tempScheduledEventsPath)) + { + return new Dictionary(); + } + + if (File.Exists(tempScheduledEventsPath)) + { + _logger.LogWarning("Found temporary scheduled events file, will try to parse and copy to main: ${Path}", + tempScheduledEventsPath); + try + { + await using var tempEventsStream = File.OpenRead(tempScheduledEventsPath); + var events = await JsonSerializer.DeserializeAsync>( + tempEventsStream, cancellationToken: ct); + File.Copy(tempScheduledEventsPath, scheduledEventsPath, true); + File.Delete(tempScheduledEventsPath); + + _logger.LogInformation("Successfully loaded temporary scheduled events file: ${Path}", + tempScheduledEventsPath); + return events; + } + catch (Exception e) + { + _logger.LogError(e, "Unable to load temporary scheduled events file: {Path}, deleting", + tempScheduledEventsPath); + File.Delete(tempScheduledEventsPath); + } + } + + try + { + await using var eventsStream = File.OpenRead(scheduledEventsPath); + return await JsonSerializer.DeserializeAsync>( + eventsStream, cancellationToken: ct); + } + catch (Exception e) + { + _logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath); + return null; + } + } + + private async Task LoadGuildSettings(string settingsPath, CancellationToken ct = default) + { + var tempSettingsPath = $"{settingsPath}.tmp"; + + if (!File.Exists(settingsPath) && !File.Exists(tempSettingsPath)) + { + return new JsonObject(); + } + + if (File.Exists(tempSettingsPath)) + { + _logger.LogWarning("Found temporary settings file, will try to parse and copy to main: ${Path}", + tempSettingsPath); + try + { + await using var tempSettingsStream = File.OpenRead(tempSettingsPath); + var jsonSettings = await JsonNode.ParseAsync(tempSettingsStream, cancellationToken: ct); + + File.Copy(tempSettingsPath, settingsPath, true); + File.Delete(tempSettingsPath); + + _logger.LogInformation("Successfully loaded temporary settings file: ${Path}", tempSettingsPath); + return jsonSettings; + } + catch (Exception e) + { + _logger.LogError(e, "Unable to load temporary settings file: {Path}, deleting", tempSettingsPath); + File.Delete(tempSettingsPath); + } + } + + try + { + await using var settingsStream = File.OpenRead(settingsPath); + return await JsonNode.ParseAsync(settingsStream, cancellationToken: ct); + } + catch (Exception e) + { + _logger.LogError(e, "Guild settings load failed: {Path}", settingsPath); + return null; + } + } + private void MigrateDataDirectory(Snowflake guildId, string newPath) { var oldPath = $"{guildId}"; diff --git a/TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs b/TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs index 51cf647..3170060 100644 --- a/TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs +++ b/TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs @@ -62,7 +62,7 @@ public sealed partial class MemberUpdateService : BackgroundService } } - private async Task TickMemberDatasAsync(Snowflake guildId, CancellationToken ct) + private async Task 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 TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole, MemberData data, - CancellationToken ct) + CancellationToken ct = default) { var failedResults = new List(); var id = data.Id.ToSnowflake(); @@ -144,7 +144,7 @@ public sealed partial class MemberUpdateService : BackgroundService } private async Task 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 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 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 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}")); diff --git a/TeamOctolings.Octobot/Services/Update/ScheduledEventUpdateService.cs b/TeamOctolings.Octobot/Services/Update/ScheduledEventUpdateService.cs index ce9c212..389a6a8 100644 --- a/TeamOctolings.Octobot/Services/Update/ScheduledEventUpdateService.cs +++ b/TeamOctolings.Octobot/Services/Update/ScheduledEventUpdateService.cs @@ -46,7 +46,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService } } - private async Task TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct) + private async Task TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct = default) { var failedResults = new List(); var data = await _guildData.GetData(guildId, ct); @@ -133,7 +133,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService private async Task 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 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 GetExternalScheduledEventCreatedEmbedDescription( @@ -319,7 +319,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService } private async Task 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 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 SendEarlyEventNotificationAsync( - IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct) + IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default) { if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) { diff --git a/TeamOctolings.Octobot/Services/Update/SongUpdateService.cs b/TeamOctolings.Octobot/Services/Update/SongUpdateService.cs index b07256f..8eaa4c2 100644 --- a/TeamOctolings.Octobot/Services/Update/SongUpdateService.cs +++ b/TeamOctolings.Octobot/Services/Update/SongUpdateService.cs @@ -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 _activityList = [new Activity("with Remora.Discord", ActivityType.Game)]; + private readonly List _activityList = [new("with Remora.Discord", ActivityType.Game)]; private readonly DiscordGatewayClient _client; private readonly GuildDataService _guildData; diff --git a/TeamOctolings.Octobot/TeamOctolings.Octobot.csproj b/TeamOctolings.Octobot/TeamOctolings.Octobot.csproj index 19e37f9..b67eaf8 100644 --- a/TeamOctolings.Octobot/TeamOctolings.Octobot.csproj +++ b/TeamOctolings.Octobot/TeamOctolings.Octobot.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net9.0 enable enable 2.0.0 @@ -24,14 +24,14 @@ - + - - - - - - + + + + + + diff --git a/TeamOctolings.Octobot/Utility.cs b/TeamOctolings.Octobot/Utility.cs index 463212b..a2f7aca 100644 --- a/TeamOctolings.Octobot/Utility.cs +++ b/TeamOctolings.Octobot/Utility.cs @@ -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> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct) + public async Task> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct = default) { var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings); if (!privateFeedback.Empty()) diff --git a/compose.example.yaml b/compose.example.yaml new file mode 100644 index 0000000..522281f --- /dev/null +++ b/compose.example.yaml @@ -0,0 +1,17 @@ +services: + octobot: + container_name: octobot + build: + context: . + args: + - PUBLISH_OPTIONS + environment: + - BOT_TOKEN + volumes: + - guild-data:/Octobot/GuildData + - logs:/Octobot/Logs + restart: unless-stopped + +volumes: + guild-data: + logs: diff --git a/docs/README.md b/docs/README.md index 7056857..ccc3b83 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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