diff --git a/.editorconfig b/.editorconfig index 8d71c08..bb647a7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -58,6 +58,7 @@ resharper_indent_nested_usings_stmt = true resharper_indent_nested_while_stmt = true resharper_indent_preprocessor_if = usual_indent resharper_indent_preprocessor_other = usual_indent +resharper_int_align_fields = true resharper_int_align_methods = true resharper_int_align_parameters = true resharper_int_align_properties = true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1c732eb..4c792a0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ * @TeamOctolings/boyfriend .github/CODEOWNERS @TeamOctolings/boyfriend-admins -*.md @mctaylors +/docs/ @mctaylors Messages.tt-ru.resx @mctaylors diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..3f1083a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,76 @@ +name: Bug Report +description: Create a report to help us improve +labels: [ "bug" ] +body: + - type: markdown + attributes: + value: | + We welcome bug reports! Please see our [contribution guidelines](docs/CONTRIBUTING.md#reporting-bugs) for more information on writing a good bug report. This template will help us gather the information we need to start the triage process. + - type: textarea + id: background + attributes: + label: Description + description: Please share a clear and concise description of the problem. + placeholder: Description + validations: + required: true + - type: textarea + id: repro-steps + attributes: + label: Reproduction Steps + description: | + Please include minimal steps to reproduce the problem if possible. E.g.: the smallest possible command/action sequence. If possible include text as text rather than screenshots (so it shows up in searches). + placeholder: Minimal Reproduction + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: | + Provide a description of the expected behavior. + placeholder: Expected behavior + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: Actual behavior + description: | + Provide a description of the actual behavior observed. If applicable please include any error messages, exception stacktraces or memory dumps. + placeholder: Actual behavior + validations: + required: true + - type: textarea + id: known-workarounds + attributes: + label: Known Workarounds + description: | + Please provide a description of any known workarounds. + placeholder: Known Workarounds + validations: + required: false + - type: textarea + id: configuration + attributes: + label: Configuration + description: | + Please provide more information on your configuration: + * Which version of .NET is the bot running on? + * What OS and version, and what distro if applicable? + * What is the architecture (x64, x86, ARM, ARM64)? + * Do you know whether it is specific to that configuration? + * If possible, please provide the Configuration.json for the affected guild + * If applicable, provide the member data JSON for the affected members + placeholder: Configuration + validations: + required: false + - type: textarea + id: other-info + attributes: + label: Other information + description: | + If you have an idea where the problem might lie, let us know that here. Please include any pointers to code, relevant changes, or related issues you know of. + placeholder: Other information + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..6dac200 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,38 @@ +name: Feature Request +description: Create a request for a feature you would like +labels: [ "type: enhancement" ] +body: + - type: textarea + id: background + attributes: + label: Description + description: Please share a clear and concise description of the feature you want. + placeholder: Description + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Please describe the solution you would like. + placeholder: Proposed Solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Considered Alternatives + description: | + Please provide a description of any alternative solutions or features you've considered. + placeholder: Considered Alternatives + validations: + required: false + - type: textarea + id: other-info + attributes: + label: Other Information + description: | + Please add any other context or screenshots about the feature request here. + placeholder: Other Information + validations: + required: false diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index ee86305..0000000 --- a/.github/README.md +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - Boyfriend Logo - - - -![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend) -![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master) -![GitHub last commit](https://img.shields.io/github/last-commit/TeamOctolings/Boyfriend) - -Beep! I'm a general-purpose bot for moderation written by [@Octol1ttle](https://github.com/Octol1ttle) in C# and Discord.Net - -# Features -* Banning, muting, kicking, etc. -* Reminding you about something if you wish -* Reminding everyone about that new event you made -* Log everything from joining the server to deleting messages - -*...and more!* - -# Getting Started - -You can read our [wiki](https://github.com/TeamOctolings/Boyfriend/wiki) in order to assemble your Boyfriend™ and moderate the server. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 36ae7a7..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: "CodeQL" -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - schedule: - - cron: '45 7 * * 2' - -jobs: - analyze: - name: Analyze code - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'csharp' ] - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - queries: +security-extended,security-and-quality - - - name: Build solution - uses: github/codeql-action/autobuild@v2 - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/resharper.yml b/.github/workflows/resharper.yml index a9cb03c..82b2562 100644 --- a/.github/workflows/resharper.yml +++ b/.github/workflows/resharper.yml @@ -18,9 +18,6 @@ jobs: contents: read security-events: write - strategy: - fail-fast: false - steps: - name: Checkout repository uses: actions/checkout@v3 @@ -29,8 +26,8 @@ jobs: run: dotnet restore - name: ReSharper CLI InspectCode - uses: muno92/resharper_inspectcode@1.6.13 + uses: muno92/resharper_inspectcode@1.7.1 with: solutionPath: ./Boyfriend.sln - ignoreIssueType: InvertIf + ignoreIssueType: InvertIf, ConvertIfStatementToReturnStatement, ConvertIfStatementToSwitchStatement solutionWideAnalysis: true diff --git a/.gitignore b/.gitignore index 3816d9a..4529511 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .idea/ *.user -token.txt bin/ obj/ /packages/ diff --git a/Boyfriend.cs b/Boyfriend.cs index aaad927..6af4326 100644 --- a/Boyfriend.cs +++ b/Boyfriend.cs @@ -1,180 +1,96 @@ -using System.Text; -using System.Timers; -using Boyfriend.Data; -using Discord; -using Discord.Rest; -using Discord.WebSocket; -using Timer = System.Timers.Timer; +using Boyfriend.Commands; +using Boyfriend.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Remora.Commands.Extensions; +using Remora.Discord.API.Abstractions.Gateway.Commands; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Discord.Caching.Extensions; +using Remora.Discord.Caching.Services; +using Remora.Discord.Commands.Extensions; +using Remora.Discord.Commands.Services; +using Remora.Discord.Gateway; +using Remora.Discord.Gateway.Extensions; +using Remora.Discord.Hosting.Extensions; +using Remora.Discord.Interactivity.Extensions; +using Remora.Rest.Core; namespace Boyfriend; -public static class Boyfriend { - public static readonly StringBuilder StringBuilder = new(); +public class Boyfriend { + public static readonly AllowedMentions NoMentions = new( + Array.Empty(), Array.Empty(), Array.Empty()); - private static readonly DiscordSocketConfig Config = new() { - MessageCacheSize = 250, - GatewayIntents - = (GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent | GatewayIntents.GuildMembers) - & ~GatewayIntents.GuildInvites, - AlwaysDownloadUsers = true, - AlwaysResolveStickers = false, - AlwaysDownloadDefaultStickers = false, - LargeThreshold = 500 - }; + public static async Task Main(string[] args) { + var host = CreateHostBuilder(args).UseConsoleLifetime().Build(); + var services = host.Services; - private static DateTimeOffset _nextSongAt = DateTimeOffset.MinValue; - private static uint _nextSongIndex; + var slashService = services.GetRequiredService(); + // Providing a guild ID to this call will result in command duplicates! + // To get rid of them, provide the ID of the guild containing duplicates, + // comment out calls to WithCommandGroup in CreateHostBuilder + // then launch the bot again and remove the guild ID + await slashService.UpdateSlashCommandsAsync(); - private static readonly (Game Song, TimeSpan Duration)[] ActivityList = { - (new Game("Masayoshi Minoshima (ft. nomico) - Bad Apple!!", ActivityType.Listening), new TimeSpan(0, 3, 40)), - (new Game("Xi - Blue Zenith", ActivityType.Listening), new TimeSpan(0, 4, 16)), - (new Game("UNDEAD CORPORATION - Everything will freeze", ActivityType.Listening), new TimeSpan(0, 3, 18)), - (new Game("Splatoon 3 - Candy-Coated Rocks", ActivityType.Listening), new TimeSpan(0, 2, 39)), - (new Game("RetroSpecter - Overtime", ActivityType.Listening), new TimeSpan(0, 4, 33)), - (new Game("SOOOO - Happppy song", ActivityType.Listening), new TimeSpan(0, 5, 24)) - }; - - public static readonly DiscordSocketClient Client = new(Config); - - private static readonly List GuildTickTasks = new(); - - private static async Task Main() { - var token = (await File.ReadAllTextAsync("token.txt")).Trim(); - - Client.Log += Log; - - await Client.LoginAsync(TokenType.Bot, token); - await Client.StartAsync(); - - EventHandler.InitEvents(); - - var timer = new Timer(); - timer.Interval = 1000; - timer.AutoReset = true; - timer.Elapsed += TickAllGuildsAsync; - if (ActivityList.Length is 0) timer.Dispose(); // CodeQL moment - timer.Start(); - - await Task.Delay(-1); + await host.RunAsync(); } - private static async void TickAllGuildsAsync(object? sender, ElapsedEventArgs e) { - if (GuildTickTasks.Count is not 0) return; + private static IHostBuilder CreateHostBuilder(string[] args) { + return Host.CreateDefaultBuilder(args) + .AddDiscordService( + services => { + var configuration = services.GetRequiredService(); - var now = DateTimeOffset.UtcNow; - foreach (var guild in Client.Guilds) GuildTickTasks.Add(TickGuildAsync(guild, now)); + return configuration.GetValue("BOT_TOKEN") + ?? throw new InvalidOperationException( + "No bot token has been provided. Set the " + + "BOT_TOKEN environment variable to a valid token."); + } + ).ConfigureServices( + (_, services) => { + services.Configure( + options => options.Intents |= GatewayIntents.MessageContents + | GatewayIntents.GuildMembers + | GatewayIntents.GuildScheduledEvents); + services.Configure( + settings => { + settings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); + settings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); + settings.SetAbsoluteExpiration(TimeSpan.FromDays(7)); + settings.SetSlidingExpiration(TimeSpan.FromDays(7)); + }); - if (now >= _nextSongAt) { - var nextSong = ActivityList[_nextSongIndex]; - await Client.SetActivityAsync(nextSong.Song); - _nextSongAt = now.Add(nextSong.Duration); - _nextSongIndex++; - if (_nextSongIndex >= ActivityList.Length) _nextSongIndex = 0; - } - - try { Task.WaitAll(GuildTickTasks.ToArray()); } catch (AggregateException ex) { - foreach (var exc in ex.InnerExceptions) - await Log( - new LogMessage( - LogSeverity.Error, nameof(Boyfriend), - "Exception while ticking guilds", exc)); - } - - GuildTickTasks.Clear(); - } - - public static Task Log(LogMessage msg) { - switch (msg.Severity) { - case LogSeverity.Critical: - Console.ForegroundColor = ConsoleColor.DarkRed; - Console.Error.WriteLine(msg.ToString()); - break; - case LogSeverity.Error: - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine(msg.ToString()); - break; - case LogSeverity.Warning: - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(msg.ToString()); - break; - case LogSeverity.Info: - Console.WriteLine(msg.ToString()); - break; - - case LogSeverity.Verbose: - case LogSeverity.Debug: - default: return Task.CompletedTask; - } - - Console.ResetColor(); - return Task.CompletedTask; - } - - private static async Task TickGuildAsync(SocketGuild guild, DateTimeOffset now) { - var data = GuildData.Get(guild); - var config = data.Preferences; - var saveData = false; - _ = int.TryParse(config["EventEarlyNotificationOffset"], out var offset); - foreach (var schEvent in guild.Events) - if (schEvent.Status is GuildScheduledEventStatus.Scheduled - && config["AutoStartEvents"] is "true" - && DateTimeOffset - .Now - >= schEvent.StartTime) await schEvent.StartAsync(); - else if (!data.EarlyNotifications.Contains(schEvent.Id) - && now >= schEvent.StartTime.Subtract(new TimeSpan(0, offset, 0))) { - data.EarlyNotifications.Add(schEvent.Id); - var receivers = config["EventStartedReceivers"]; - var role = guild.GetRole(ulong.Parse(config["EventNotificationRole"])); - var mentions = StringBuilder; - - if (receivers.Contains("role") && role is not null) mentions.Append($"{role.Mention} "); - if (receivers.Contains("users") || receivers.Contains("interested")) - mentions = (await schEvent.GetUsersAsync(15)) - .Where(user => role is null || !((RestGuildUser)user).RoleIds.Contains(role.Id)) - .Aggregate(mentions, (current, user) => current.Append($"{user.Mention} ")); - - await Utils.GetEventNotificationChannel(guild)?.SendMessageAsync( - string.Format( - Messages.EventEarlyNotification, - mentions, - Utils.Wrap(schEvent.Name), - schEvent.StartTime.ToUnixTimeSeconds().ToString()))!; - mentions.Clear(); - } - - foreach (var mData in data.MemberData.Values) { - var user = guild.GetUser(mData.Id); - if (now >= mData.BannedUntil && await guild.GetBanAsync(mData.Id) is not null) - _ = guild.RemoveBanAsync(mData.Id); - if (!mData.IsInGuild) continue; - if (mData.MutedUntil is null - && ulong.TryParse(config["StarterRole"], out var starterRoleId) - && guild.GetRole(starterRoleId) is not null - && !mData.Roles.Contains(starterRoleId)) _ = user.AddRoleAsync(starterRoleId); - - if (now >= mData.MutedUntil) { - saveData = await Utils.UnmuteMemberAsync( - data, Client.CurrentUser.ToString(), user, - Messages.PunishmentExpired); - } - - for (var i = mData.Reminders.Count - 1; i >= 0; i--) { - var reminder = mData.Reminders[i]; - if (now < reminder.RemindAt) continue; - - var channel = guild.GetTextChannel(reminder.ReminderChannel); - var toSend = $"{ReplyEmojis.Reminder} {user.Mention} {Utils.Wrap(reminder.ReminderText)}"; - if (channel is not null) - await channel.SendMessageAsync(toSend); - else - await Utils.SendDirectMessage(user, toSend); - - mData.Reminders.RemoveAt(i); - saveData = true; - } - } - - if (saveData) await data.Save(true); + services.AddTransient() + .AddDiscordCaching() + .AddDiscordCommands(true) + .AddPreparationErrorEvent() + .AddPostExecutionEvent() + .AddInteractivity() + .AddInteractionGroup() + .AddSingleton() + .AddSingleton() + .AddHostedService() + .AddCommandTree() + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup(); + var responderTypes = typeof(Boyfriend).Assembly + .GetExportedTypes() + .Where(t => t.IsResponder()); + foreach (var responderType in responderTypes) services.AddResponder(responderType); + } + ).ConfigureLogging( + c => c.AddConsole() + .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning) + .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning) + ); } } diff --git a/Boyfriend.csproj b/Boyfriend.csproj index 0192abe..29483d7 100644 --- a/Boyfriend.csproj +++ b/Boyfriend.csproj @@ -5,9 +5,9 @@ net7.0 enable enable - 1.0.0 + 2.0.0 Boyfriend - Octol1ttle, mctaylors + Octol1ttle, mctaylors, neroduckale AGPLv3 https://github.com/TeamOctolings/Boyfriend https://github.com/TeamOctolings/Boyfriend/blob/master/LICENSE @@ -19,8 +19,31 @@ - - - + + + + + + + + + + + True + True + Messages.resx + + + + + + + ResXFileCodeGenerator + Messages.Designer.cs + + + + + diff --git a/ColorsList.cs b/ColorsList.cs new file mode 100644 index 0000000..bdd5bce --- /dev/null +++ b/ColorsList.cs @@ -0,0 +1,18 @@ +using System.Drawing; + +namespace Boyfriend; + +/// +/// Contains all colors used in embeds. +/// +public static class ColorsList { + public static readonly Color Default = Color.Gray; + public static readonly Color Red = Color.Firebrick; + public static readonly Color Green = Color.PaleGreen; + public static readonly Color Yellow = Color.Gold; + public static readonly Color Blue = Color.RoyalBlue; + public static readonly Color Magenta = Color.Orchid; + public static readonly Color Cyan = Color.LightSkyBlue; + public static readonly Color Black = Color.Black; + public static readonly Color White = Color.WhiteSmoke; +} diff --git a/CommandProcessor.cs b/CommandProcessor.cs deleted file mode 100644 index bd8b53e..0000000 --- a/CommandProcessor.cs +++ /dev/null @@ -1,321 +0,0 @@ -using System.Text; -using Boyfriend.Commands; -using Boyfriend.Data; -using Discord; -using Discord.Commands; -using Discord.WebSocket; - -namespace Boyfriend; - -public sealed class CommandProcessor { - private static readonly string Mention = $"<@{Boyfriend.Client.CurrentUser.Id}>"; - private static readonly TimeSpan Infinity = TimeSpan.FromMilliseconds(-1); - - public static readonly ICommand[] Commands = { - new BanCommand(), new ClearCommand(), new HelpCommand(), - new KickCommand(), new MuteCommand(), new PingCommand(), - new RemindCommand(), new SettingsCommand(), new UnbanCommand(), - new UnmuteCommand() - }; - - private readonly StringBuilder _stackedPrivateFeedback = new(); - private readonly StringBuilder _stackedPublicFeedback = new(); - private readonly StringBuilder _stackedReplyMessage = new(); - private readonly List _tasks = new(); - - public readonly SocketCommandContext Context; - - public bool ConfigWriteScheduled = false; - - public CommandProcessor(SocketUserMessage message) { - Context = new SocketCommandContext(Boyfriend.Client, message); - } - - public async Task HandleCommandAsync() { - var guild = Context.Guild; - var data = GuildData.Get(guild); - Utils.SetCurrentLanguage(guild); - - var list = Context.Message.Content.Split("\n"); - var cleanList = Context.Message.CleanContent.Split("\n"); - for (var i = 0; i < list.Length; i++) - _tasks.Add(RunCommandOnLine(list[i], cleanList[i], data.Preferences["Prefix"])); - - try { Task.WaitAll(_tasks.ToArray()); } catch (AggregateException e) { - foreach (var ex in e.InnerExceptions) - await Boyfriend.Log( - new LogMessage( - LogSeverity.Error, nameof(CommandProcessor), - "Exception while executing commands", ex)); - } - - _tasks.Clear(); - - if (ConfigWriteScheduled) await data.Save(true); - - SendFeedbacks(); - } - - private async Task RunCommandOnLine(string line, string cleanLine, string prefix) { - var prefixed = line.StartsWith(prefix); - if (!prefixed && !line.StartsWith(Mention)) return; - foreach (var command in Commands) { - var lineNoMention = line.Remove(0, prefixed ? prefix.Length : Mention.Length); - if (!command.Aliases.Contains(lineNoMention.Trim().Split()[0])) continue; - - var args = lineNoMention.Trim().Split().Skip(1).ToArray(); - var cleanArgs = cleanLine.Split().Skip(lineNoMention.StartsWith(" ") ? 2 : 1).ToArray(); - await command.RunAsync(this, args, cleanArgs); - if (_stackedReplyMessage.Length > 0) _ = Context.Channel.TriggerTypingAsync(); - return; - } - } - - public void Reply(string response, string? customEmoji = null) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, $"{customEmoji ?? ReplyEmojis.Success} {response}", - Context.Message); - } - - public void Audit(string action, bool isPublic = true) { - var format = $"*[{Context.User.Mention}: {action}]*"; - var data = GuildData.Get(Context.Guild); - if (isPublic) Utils.SafeAppendToBuilder(_stackedPublicFeedback, format, data.PublicFeedbackChannel); - Utils.SafeAppendToBuilder(_stackedPrivateFeedback, format, data.PrivateFeedbackChannel); - if (_tasks.Count is 0) SendFeedbacks(false); - } - - private void SendFeedbacks(bool reply = true) { - var hasReply = _stackedReplyMessage.Length > 0; - if (reply && hasReply) - _ = Context.Message.ReplyAsync(_stackedReplyMessage.ToString(), false, null, AllowedMentions.None); - - var data = GuildData.Get(Context.Guild); - var adminChannel = data.PrivateFeedbackChannel; - var systemChannel = data.PublicFeedbackChannel; - if (_stackedPrivateFeedback.Length > 0 - && adminChannel is not null - && (adminChannel.Id != Context.Message.Channel.Id || !hasReply)) { - _ = Utils.SilentSendAsync(adminChannel, _stackedPrivateFeedback.ToString()); - _stackedPrivateFeedback.Clear(); - } - - if (_stackedPublicFeedback.Length > 0 - && systemChannel is not null - && systemChannel.Id != adminChannel?.Id - && (systemChannel.Id != Context.Message.Channel.Id || !hasReply)) { - _ = Utils.SilentSendAsync(systemChannel, _stackedPublicFeedback.ToString()); - _stackedPublicFeedback.Clear(); - } - } - - public string? GetRemaining(string[] from, int startIndex, string? argument) { - if (startIndex >= from.Length && argument is not null) - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.MissingArgument} {Utils.GetMessage($"Missing{argument}")}", Context.Message); - else return string.Join(" ", from, startIndex, from.Length - startIndex); - return null; - } - - public (ulong Id, SocketUser? User)? GetUser(string[] args, string[] cleanArgs, int index) { - if (index >= args.Length) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingUser}", - Context.Message); - return null; - } - - var mention = Utils.ParseMention(args[index]); - if (mention is 0) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.InvalidArgument} {string.Format(Messages.InvalidUser, Utils.Wrap(cleanArgs[index]))}", - Context.Message); - return null; - } - - var exists = Utils.UserExists(mention); - if (!exists) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.Error} {string.Format(Messages.UserNotFound, Utils.Wrap(cleanArgs[index]))}", - Context.Message); - return null; - } - - return (mention, Boyfriend.Client.GetUser(mention)); - } - - public bool HasPermission(GuildPermission permission) { - if (!Context.Guild.CurrentUser.GuildPermissions.Has(permission)) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.NoPermission} {Utils.GetMessage($"BotCannot{permission}")}", - Context.Message); - return false; - } - - if (!GetMember().GuildPermissions.Has(permission) - && Context.Guild.OwnerId != Context.User.Id) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.NoPermission} {Utils.GetMessage($"UserCannot{permission}")}", - Context.Message); - return false; - } - - return true; - } - - private SocketGuildUser GetMember() { - return GetMember(Context.User.Id)!; - } - - public SocketGuildUser? GetMember(ulong id) { - return Context.Guild.GetUser(id); - } - - public SocketGuildUser? GetMember(string[] args, int index) { - if (index >= args.Length) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingMember}", - Context.Message); - return null; - } - - var member = Context.Guild.GetUser(Utils.ParseMention(args[index])); - if (member is null) - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.InvalidArgument} {Messages.InvalidMember}", - Context.Message); - return member; - } - - public ulong? GetBan(string[] args, int index) { - if (index >= args.Length) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingUser}", - Context.Message); - return null; - } - - var id = Utils.ParseMention(args[index]); - if (Context.Guild.GetBanAsync(id) is null) { - Utils.SafeAppendToBuilder(_stackedReplyMessage, Messages.UserNotBanned, Context.Message); - return null; - } - - return id; - } - - public int? GetNumberRange(string[] args, int index, int min, int max, string? argument) { - if (index >= args.Length) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.MissingArgument} {string.Format(Messages.MissingNumber, min.ToString(), max.ToString())}", - Context.Message); - return null; - } - - if (!int.TryParse(args[index], out var i)) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}Invalid"), min.ToString(), max.ToString(), Utils.Wrap(args[index]))}", - Context.Message); - return null; - } - - if (argument is null) return i; - if (i < min) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}TooSmall"), min.ToString())}", - Context.Message); - return null; - } - - if (i > max) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}TooLarge"), max.ToString())}", - Context.Message); - return null; - } - - return i; - } - - public static TimeSpan GetTimeSpan(string[] args, int index) { - if (index >= args.Length) return Infinity; - var chars = args[index].AsSpan(); - var numberBuilder = Boyfriend.StringBuilder; - int days = 0, hours = 0, minutes = 0, seconds = 0; - foreach (var c in chars) - if (char.IsDigit(c)) { numberBuilder.Append(c); } else { - if (numberBuilder.Length is 0) return Infinity; - switch (c) { - case 'd' or 'D' or 'д' or 'Д': - days += int.Parse(numberBuilder.ToString()); - numberBuilder.Clear(); - break; - case 'h' or 'H' or 'ч' or 'Ч': - hours += int.Parse(numberBuilder.ToString()); - numberBuilder.Clear(); - break; - case 'm' or 'M' or 'м' or 'М': - minutes += int.Parse(numberBuilder.ToString()); - numberBuilder.Clear(); - break; - case 's' or 'S' or 'с' or 'С': - seconds += int.Parse(numberBuilder.ToString()); - numberBuilder.Clear(); - break; - default: return Infinity; - } - } - - numberBuilder.Clear(); - return new TimeSpan(days, hours, minutes, seconds); - } - - public bool CanInteractWith(SocketGuildUser user, string action) { - if (Context.User.Id == user.Id) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Themselves")}", Context.Message); - return false; - } - - if (Context.Guild.CurrentUser.Id == user.Id) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Bot")}", Context.Message); - return false; - } - - if (Context.Guild.Owner.Id == user.Id) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Owner")}", Context.Message); - return false; - } - - if (Context.Guild.CurrentUser.Hierarchy <= user.Hierarchy) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"BotCannot{action}Target")}", Context.Message); - return false; - } - - if (Context.Guild.Owner.Id != Context.User.Id && GetMember().Hierarchy <= user.Hierarchy) { - Utils.SafeAppendToBuilder( - _stackedReplyMessage, - $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Target")}", Context.Message); - return false; - } - - return true; - } -} diff --git a/Commands/AboutCommandGroup.cs b/Commands/AboutCommandGroup.cs new file mode 100644 index 0000000..e4820c6 --- /dev/null +++ b/Commands/AboutCommandGroup.cs @@ -0,0 +1,75 @@ +using System.ComponentModel; +using System.Text; +using Boyfriend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend.Commands; + +/// +/// Handles the command to show information about this bot: /about. +/// +public class AboutCommandGroup : CommandGroup { + private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" }; + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestUserAPI _userApi; + + public AboutCommandGroup( + ICommandContext context, GuildDataService dataService, + FeedbackService feedbackService, IDiscordRestUserAPI userApi) { + _context = context; + _dataService = dataService; + _feedbackService = feedbackService; + _userApi = userApi; + } + + /// + /// A slash command that shows information about this bot. + /// + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("about")] + [Description("Shows Boyfriend's developers")] + public async Task SendAboutBotAsync() { + if (!_context.TryGetContextIDs(out var guildId, out _, out _)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); + Messages.Culture = cfg.GetCulture(); + + var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers)); + foreach (var dev in Developers) + builder.AppendLine($"@{dev} — {$"AboutDeveloper@{dev}".Localized()}"); + + builder.AppendLine() + .AppendLine(Markdown.Bold(Messages.AboutTitleWiki)) + .AppendLine("https://github.com/TeamOctolings/Boyfriend/wiki"); + + var embed = new EmbedBuilder().WithSmallTitle(Messages.AboutBot, currentUser) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Cyan) + .WithImageUrl( + "https://media.discordapp.net/attachments/837385840946053181/1125009665592393738/boyfriend.png") + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } +} diff --git a/Commands/BanCommand.cs b/Commands/BanCommand.cs deleted file mode 100644 index f12dbc7..0000000 --- a/Commands/BanCommand.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Boyfriend.Data; -using Discord; -using Discord.WebSocket; - -namespace Boyfriend.Commands; - -public sealed class BanCommand : ICommand { - public string[] Aliases { get; } = { "ban", "бан" }; - - public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - var toBan = cmd.GetUser(args, cleanArgs, 0); - if (toBan is null || !cmd.HasPermission(GuildPermission.BanMembers)) return; - - var memberToBan = cmd.GetMember(toBan.Value.Id); - if (memberToBan is not null && !cmd.CanInteractWith(memberToBan, "Ban")) return; - - var duration = CommandProcessor.GetTimeSpan(args, 1); - var reason = cmd.GetRemaining(args, duration.TotalSeconds < 1 ? 1 : 2, "BanReason"); - if (reason is not null) await BanUserAsync(cmd, toBan.Value, duration, reason); - } - - private static async Task BanUserAsync( - CommandProcessor cmd, (ulong Id, SocketUser? User) toBan, TimeSpan duration, - string reason) { - var author = cmd.Context.User; - var guild = cmd.Context.Guild; - if (toBan.User is not null) - await Utils.SendDirectMessage( - toBan.User, - string.Format(Messages.YouWereBanned, author.Mention, guild.Name, Utils.Wrap(reason))); - - var guildBanMessage = $"({author}) {reason}"; - await guild.AddBanAsync(toBan.Id, 0, guildBanMessage); - - var memberData = GuildData.Get(guild).MemberData[toBan.Id]; - memberData.BannedUntil - = duration.TotalSeconds < 1 ? DateTimeOffset.MaxValue : DateTimeOffset.UtcNow.Add(duration); - memberData.Roles.Clear(); - - cmd.ConfigWriteScheduled = true; - - var feedback = string.Format( - Messages.FeedbackUserBanned, $"<@{toBan.Id.ToString()}>", - Utils.GetHumanizedTimeSpan(duration), Utils.Wrap(reason)); - cmd.Reply(feedback, ReplyEmojis.Banned); - cmd.Audit(feedback); - } -} diff --git a/Commands/BanCommandGroup.cs b/Commands/BanCommandGroup.cs new file mode 100644 index 0000000..a525afb --- /dev/null +++ b/Commands/BanCommandGroup.cs @@ -0,0 +1,275 @@ +using System.ComponentModel; +using System.Text; +using Boyfriend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend.Commands; + +/// +/// Handles commands related to ban management: /ban and /unban. +/// +public class BanCommandGroup : CommandGroup { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly IDiscordRestUserAPI _userApi; + private readonly UtilityService _utility; + + public BanCommandGroup( + ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService, + FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, + UtilityService utility) { + _context = context; + _channelApi = channelApi; + _dataService = dataService; + _feedbackService = feedbackService; + _guildApi = guildApi; + _userApi = userApi; + _utility = utility; + } + + /// + /// A slash command that bans a Discord user with the specified reason. + /// + /// The user to ban. + /// The duration for this ban. The user will be automatically unbanned after this duration. + /// + /// The reason for this ban. Must be encoded with when passed to + /// . + /// + /// + /// 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. + /// + /// + [Command("ban", "бан")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.BanMembers)] + [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] + [Description("Ban user")] + public async Task BanUserAsync( + [Description("User to ban")] IUser target, + [Description("Ban reason")] string reason, + [Description("Ban duration")] + TimeSpan? duration = null) { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + // The current user's avatar is used when sending error messages + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var data = await _dataService.GetData(guildId.Value, CancellationToken); + var cfg = data.Configuration; + Messages.Culture = data.Culture; + + var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken); + if (existingBanResult.IsDefined()) { + var embed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, currentUser) + .WithColour(ColorsList.Red).Build(); + + if (!embed.IsDefined(out var alreadyBuilt)) + return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken); + } + + var interactionResult + = await _utility.CheckInteractionsAsync(guildId.Value, userId.Value, target.ID, "Ban", CancellationToken); + if (!interactionResult.IsSuccess) + return Result.FromError(interactionResult); + + Result responseEmbed; + if (interactionResult.Entity is not null) { + responseEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) + .WithColour(ColorsList.Red).Build(); + } else { + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason)); + if (duration is not null) + builder.Append( + string.Format( + Messages.DescriptionActionExpiresAt, + Markdown.Timestamp(DateTimeOffset.UtcNow.Add(duration.Value)))); + var description = builder.ToString(); + + var dmChannelResult = await _userApi.CreateDMAsync(target.ID, CancellationToken); + if (dmChannelResult.IsDefined(out var dmChannel)) { + var guildResult = await _guildApi.GetGuildAsync(guildId.Value, ct: CancellationToken); + if (!guildResult.IsDefined(out var guild)) + return Result.FromError(guildResult); + + var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) + .WithTitle(Messages.YouWereBanned) + .WithDescription(description) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + if (!dmEmbed.IsDefined(out var dmBuilt)) + return Result.FromError(dmEmbed); + await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: CancellationToken); + } + + var banResult = await _guildApi.CreateGuildBanAsync( + guildId.Value, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(), + ct: CancellationToken); + if (!banResult.IsSuccess) + return Result.FromError(banResult.Error); + var memberData = data.GetMemberData(target.ID); + memberData.BannedUntil + = duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue; + memberData.Roles.Clear(); + + responseEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserBanned, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) + || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + var logEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserBanned, target.GetTag()), target) + .WithDescription(description) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + if (!logEmbed.IsDefined(out var logBuilt)) + return Result.FromError(logEmbed); + + var builtArray = new[] { logBuilt }; + // Not awaiting to reduce response time + if (cfg.PublicFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel + && cfg.PrivateFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + } + } + + if (!responseEmbed.IsDefined(out var built)) + return Result.FromError(responseEmbed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } + + /// + /// A slash command that unbans a Discord user with the specified reason. + /// + /// The user to unban. + /// + /// The reason for this unban. Must be encoded with when passed to + /// . + /// + /// + /// 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. + /// + /// + /// + [Command("unban")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.BanMembers)] + [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] + [Description("Unban user")] + public async Task UnbanUserAsync( + [Description("User to unban")] IUser target, + [Description("Unban reason")] + string reason) { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + // The current user's avatar is used when sending error messages + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); + Messages.Culture = cfg.GetCulture(); + + var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken); + if (!existingBanResult.IsDefined()) { + var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotBanned, currentUser) + .WithColour(ColorsList.Red).Build(); + + if (!embed.IsDefined(out var alreadyBuilt)) + return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken); + } + + // Needed to get the tag and avatar + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var unbanResult = await _guildApi.RemoveGuildBanAsync( + guildId.Value, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), + ct: CancellationToken); + if (!unbanResult.IsSuccess) + return Result.FromError(unbanResult.Error); + + var responseEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserUnbanned, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) + || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + var logEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserUnbanned, target.GetTag()), target) + .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Green) + .Build(); + + if (!logEmbed.IsDefined(out var logBuilt)) + return Result.FromError(logEmbed); + + var builtArray = new[] { logBuilt }; + + // Not awaiting to reduce response time + if (cfg.PublicFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel + && cfg.PrivateFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + } + + if (!responseEmbed.IsDefined(out var built)) + return Result.FromError(responseEmbed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } +} diff --git a/Commands/ClearCommand.cs b/Commands/ClearCommand.cs deleted file mode 100644 index 4d10595..0000000 --- a/Commands/ClearCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Diagnostics; -using Discord; -using Discord.WebSocket; - -namespace Boyfriend.Commands; - -public sealed class ClearCommand : ICommand { - public string[] Aliases { get; } = { "clear", "purge", "очистить", "стереть" }; - - public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - if (cmd.Context.Channel is not SocketTextChannel channel) throw new UnreachableException(); - - if (!cmd.HasPermission(GuildPermission.ManageMessages)) return; - - var toDelete = cmd.GetNumberRange(cleanArgs, 0, 1, 200, "ClearAmount"); - if (toDelete is null) return; - var messages = await channel.GetMessagesAsync((int)(toDelete + 1)).FlattenAsync(); - - var user = (SocketGuildUser)cmd.Context.User; - var msgArray = messages.Reverse().ToArray(); - await channel.DeleteMessagesAsync(msgArray, Utils.GetRequestOptions(user.ToString()!)); - - foreach (var msg in msgArray.Where(m => !m.Author.IsBot)) - cmd.Audit( - string.Format( - Messages.CachedMessageCleared, msg.Author.Mention, - Utils.MentionChannel(channel.Id), - Utils.Wrap(msg.CleanContent)), false); - } -} diff --git a/Commands/ClearCommandGroup.cs b/Commands/ClearCommandGroup.cs new file mode 100644 index 0000000..de44fbb --- /dev/null +++ b/Commands/ClearCommandGroup.cs @@ -0,0 +1,120 @@ +using System.ComponentModel; +using System.Text; +using Boyfriend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend.Commands; + +/// +/// Handles the command to clear messages in a channel: /clear. +/// +public class ClearCommandGroup : CommandGroup { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestUserAPI _userApi; + + public ClearCommandGroup( + IDiscordRestChannelAPI channelApi, ICommandContext context, GuildDataService dataService, + FeedbackService feedbackService, IDiscordRestUserAPI userApi) { + _channelApi = channelApi; + _context = context; + _dataService = dataService; + _feedbackService = feedbackService; + _userApi = userApi; + } + + /// + /// A slash command that clears messages in the channel it was executed. + /// + /// The amount of messages to clear. + /// + /// 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. + /// + [Command("clear", "очистить")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.ManageMessages)] + [Description("Remove multiple messages")] + public async Task ClearMessagesAsync( + [Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)] + int amount) { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + var messagesResult = await _channelApi.GetChannelMessagesAsync( + channelId.Value, limit: amount + 1, ct: CancellationToken); + if (!messagesResult.IsDefined(out var messages)) + return Result.FromError(messagesResult); + + var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); + Messages.Culture = cfg.GetCulture(); + + var idList = new List(messages.Count); + var builder = new StringBuilder().AppendLine(Mention.Channel(channelId.Value)).AppendLine(); + for (var i = messages.Count - 1; i >= 1; i--) { // '>= 1' to skip last message ('Boyfriend is thinking...') + var message = messages[i]; + idList.Add(message.ID); + builder.AppendLine(string.Format(Messages.MessageFrom, Mention.User(message.Author))); + builder.Append(message.Content.InBlockCode()); + } + + var description = builder.ToString(); + + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var deleteResult = await _channelApi.BulkDeleteMessagesAsync( + channelId.Value, idList, user.GetTag().EncodeHeader(), CancellationToken); + if (!deleteResult.IsSuccess) + return Result.FromError(deleteResult.Error); + + // The current user's avatar is used when sending messages + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var title = string.Format(Messages.MessagesCleared, amount.ToString()); + if (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value) { + var logEmbed = new EmbedBuilder().WithSmallTitle(title, currentUser) + .WithDescription(description) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + if (!logEmbed.IsDefined(out var logBuilt)) + return Result.FromError(logEmbed); + + // Not awaiting to reduce response time + if (cfg.PrivateFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { logBuilt }, + ct: CancellationToken); + } + + var embed = new EmbedBuilder().WithSmallTitle(title, currentUser) + .WithColour(ColorsList.Green).Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } +} diff --git a/Commands/ErrorLoggingEvents.cs b/Commands/ErrorLoggingEvents.cs new file mode 100644 index 0000000..30869b4 --- /dev/null +++ b/Commands/ErrorLoggingEvents.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Logging; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Services; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global + +namespace Boyfriend.Commands; + +/// +/// Handles error logging for slash commands that couldn't be successfully prepared. +/// +public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent { + private readonly ILogger _logger; + + public ErrorLoggingPreparationErrorEvent(ILogger logger) { + _logger = logger; + } + + /// + /// Logs a warning using the injected if the has not + /// succeeded. + /// + /// The context of the slash command. Unused. + /// The result whose success is checked. + /// The cancellation token for this operation. Unused. + /// A result which has succeeded. + public Task PreparationFailed( + IOperationContext context, IResult preparationResult, CancellationToken ct = default) { + if (!preparationResult.IsSuccess) + _logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message); + + return Task.FromResult(Result.FromSuccess()); + } +} + +/// +/// Handles error logging for slash command groups. +/// +public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent { + private readonly ILogger _logger; + + public ErrorLoggingPostExecutionEvent(ILogger logger) { + _logger = logger; + } + + /// + /// Logs a warning using the injected if the has not + /// succeeded. + /// + /// The context of the slash command. Unused. + /// The result whose success is checked. + /// The cancellation token for this operation. Unused. + /// A result which has succeeded. + public Task AfterExecutionAsync( + ICommandContext context, IResult commandResult, CancellationToken ct = default) { + if (!commandResult.IsSuccess) + _logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message); + + return Task.FromResult(Result.FromSuccess()); + } +} diff --git a/Commands/HelpCommand.cs b/Commands/HelpCommand.cs deleted file mode 100644 index 72b788b..0000000 --- a/Commands/HelpCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Boyfriend.Data; -using Humanizer; - -namespace Boyfriend.Commands; - -public sealed class HelpCommand : ICommand { - public string[] Aliases { get; } = { "help", "помощь", "справка" }; - - public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - var prefix = GuildData.Get(cmd.Context.Guild).Preferences["Prefix"]; - var toSend = Boyfriend.StringBuilder.Append(Messages.CommandHelp); - - foreach (var command in CommandProcessor.Commands) - toSend.Append( - $"\n`{prefix}{command.Aliases[0]}`: {Utils.GetMessage($"CommandDescription{command.Aliases[0].Titleize()}")}"); - cmd.Reply(toSend.ToString(), ReplyEmojis.Help); - toSend.Clear(); - - return Task.CompletedTask; - } -} diff --git a/Commands/ICommand.cs b/Commands/ICommand.cs deleted file mode 100644 index 5dccb98..0000000 --- a/Commands/ICommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Boyfriend.Commands; - -public interface ICommand { - public string[] Aliases { get; } - - public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs); -} diff --git a/Commands/KickCommand.cs b/Commands/KickCommand.cs deleted file mode 100644 index 6d5689b..0000000 --- a/Commands/KickCommand.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Boyfriend.Data; -using Discord; -using Discord.WebSocket; - -namespace Boyfriend.Commands; - -public sealed class KickCommand : ICommand { - public string[] Aliases { get; } = { "kick", "кик", "выгнать" }; - - public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - var toKick = cmd.GetMember(args, 0); - if (toKick is null || !cmd.HasPermission(GuildPermission.KickMembers)) return; - - if (cmd.CanInteractWith(toKick, "Kick")) - await KickMemberAsync(cmd, toKick, cmd.GetRemaining(args, 1, "KickReason")); - } - - private static async Task KickMemberAsync(CommandProcessor cmd, SocketGuildUser toKick, string? reason) { - if (reason is null) return; - var guildKickMessage = $"({cmd.Context.User}) {reason}"; - - await Utils.SendDirectMessage(toKick, - string.Format(Messages.YouWereKicked, cmd.Context.User.Mention, cmd.Context.Guild.Name, - Utils.Wrap(reason))); - - GuildData.Get(cmd.Context.Guild).MemberData[toKick.Id].Roles.Clear(); - cmd.ConfigWriteScheduled = true; - - await toKick.KickAsync(guildKickMessage); - var format = string.Format(Messages.FeedbackMemberKicked, toKick.Mention, Utils.Wrap(reason)); - cmd.Reply(format, ReplyEmojis.Kicked); - cmd.Audit(format); - } -} diff --git a/Commands/KickCommandGroup.cs b/Commands/KickCommandGroup.cs new file mode 100644 index 0000000..e70a903 --- /dev/null +++ b/Commands/KickCommandGroup.cs @@ -0,0 +1,164 @@ +using System.ComponentModel; +using Boyfriend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend.Commands; + +/// +/// Handles the command to kick members of a guild: /kick. +/// +public class KickCommandGroup : CommandGroup { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly IDiscordRestUserAPI _userApi; + private readonly UtilityService _utility; + + public KickCommandGroup( + ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService, + FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, + UtilityService utility) { + _context = context; + _channelApi = channelApi; + _dataService = dataService; + _feedbackService = feedbackService; + _guildApi = guildApi; + _userApi = userApi; + _utility = utility; + } + + /// + /// A slash command that kicks a Discord user with the specified reason. + /// + /// The user to kick. + /// + /// The reason for this kick. Must be encoded with when passed to + /// . + /// + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was kicked and vice-versa. + /// + [Command("kick", "кик")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.KickMembers)] + [RequireBotDiscordPermissions(DiscordPermission.KickMembers)] + [Description("Kick member")] + public async Task KickUserAsync( + [Description("Member to kick")] IUser target, + [Description("Kick reason")] string reason) { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + // The current user's avatar is used when sending error messages + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var data = await _dataService.GetData(guildId.Value, CancellationToken); + var cfg = data.Configuration; + Messages.Culture = cfg.GetCulture(); + + var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); + if (!memberResult.IsSuccess) { + var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser) + .WithColour(ColorsList.Red).Build(); + + if (!embed.IsDefined(out var alreadyBuilt)) + return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken); + } + + var interactionResult + = await _utility.CheckInteractionsAsync(guildId.Value, userId.Value, target.ID, "Kick", CancellationToken); + if (!interactionResult.IsSuccess) + return Result.FromError(interactionResult); + + Result responseEmbed; + if (interactionResult.Entity is not null) { + responseEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) + .WithColour(ColorsList.Red).Build(); + } else { + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var dmChannelResult = await _userApi.CreateDMAsync(target.ID, CancellationToken); + if (dmChannelResult.IsDefined(out var dmChannel)) { + var guildResult = await _guildApi.GetGuildAsync(guildId.Value, ct: CancellationToken); + if (!guildResult.IsDefined(out var guild)) + return Result.FromError(guildResult); + + var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) + .WithTitle(Messages.YouWereKicked) + .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + if (!dmEmbed.IsDefined(out var dmBuilt)) + return Result.FromError(dmEmbed); + await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: CancellationToken); + } + + var kickResult = await _guildApi.RemoveGuildMemberAsync( + guildId.Value, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), + ct: CancellationToken); + if (!kickResult.IsSuccess) + return Result.FromError(kickResult.Error); + data.GetMemberData(target.ID).Roles.Clear(); + + responseEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserKicked, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) + || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + var logEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserKicked, target.GetTag()), target) + .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + if (!logEmbed.IsDefined(out var logBuilt)) + return Result.FromError(logEmbed); + + var builtArray = new[] { logBuilt }; + // Not awaiting to reduce response time + if (cfg.PublicFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel + && cfg.PrivateFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + } + } + + if (!responseEmbed.IsDefined(out var built)) + return Result.FromError(responseEmbed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } +} diff --git a/Commands/MuteCommand.cs b/Commands/MuteCommand.cs deleted file mode 100644 index 241c0e2..0000000 --- a/Commands/MuteCommand.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Boyfriend.Data; -using Discord; - -namespace Boyfriend.Commands; - -public sealed class MuteCommand : ICommand { - public string[] Aliases { get; } = { "mute", "timeout", "заглушить", "мут" }; - - public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - var toMute = cmd.GetMember(args, 0); - if (toMute is null) return; - - var duration = CommandProcessor.GetTimeSpan(args, 1); - var reason = cmd.GetRemaining(args, duration.TotalSeconds < 1 ? 1 : 2, "MuteReason"); - if (reason is null) return; - var guildData = GuildData.Get(cmd.Context.Guild); - var role = guildData.MuteRole; - - if ((role is not null && toMute.Roles.Contains(role)) - || (toMute.TimedOutUntil is not null - && toMute.TimedOutUntil.Value - > DateTimeOffset.UtcNow)) { - cmd.Reply(Messages.MemberAlreadyMuted, ReplyEmojis.Error); - return; - } - - if (cmd.HasPermission(GuildPermission.ModerateMembers) && cmd.CanInteractWith(toMute, "Mute")) - await MuteMemberAsync(cmd, toMute, duration, guildData, reason); - } - - private static async Task MuteMemberAsync( - CommandProcessor cmd, IGuildUser toMute, - TimeSpan duration, GuildData data, string reason) { - var requestOptions = Utils.GetRequestOptions($"({cmd.Context.User}) {reason}"); - var role = data.MuteRole; - var hasDuration = duration.TotalSeconds > 0; - var memberData = data.MemberData[toMute.Id]; - - if (role is not null) { - memberData.MutedUntil = DateTimeOffset.UtcNow.Add(duration); - if (data.Preferences["RemoveRolesOnMute"] is "true") { - memberData.Roles = toMute.RoleIds.ToList(); - memberData.Roles.Remove(cmd.Context.Guild.Id); - await toMute.RemoveRolesAsync(memberData.Roles, requestOptions); - } - - await toMute.AddRoleAsync(role, requestOptions); - } else { - if (!hasDuration || duration.TotalDays > 28) { - cmd.Reply(Messages.DurationRequiredForTimeOuts, ReplyEmojis.Error); - return; - } - - if (toMute.IsBot) { - cmd.Reply(Messages.CannotTimeOutBot, ReplyEmojis.Error); - return; - } - - await toMute.SetTimeOutAsync(duration, requestOptions); - } - - cmd.ConfigWriteScheduled = true; - - var feedback = string.Format( - Messages.FeedbackMemberMuted, toMute.Mention, - Utils.GetHumanizedTimeSpan(duration), - Utils.Wrap(reason)); - cmd.Reply(feedback, ReplyEmojis.Muted); - cmd.Audit(feedback); - } -} diff --git a/Commands/MuteCommandGroup.cs b/Commands/MuteCommandGroup.cs new file mode 100644 index 0000000..cdf750d --- /dev/null +++ b/Commands/MuteCommandGroup.cs @@ -0,0 +1,258 @@ +using System.ComponentModel; +using System.Text; +using Boyfriend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend.Commands; + +/// +/// Handles commands related to mute management: /mute and /unmute. +/// +public class MuteCommandGroup : CommandGroup { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly IDiscordRestUserAPI _userApi; + private readonly UtilityService _utility; + + public MuteCommandGroup( + ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService, + FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, + UtilityService utility) { + _context = context; + _channelApi = channelApi; + _dataService = dataService; + _feedbackService = feedbackService; + _guildApi = guildApi; + _userApi = userApi; + _utility = utility; + } + + /// + /// A slash command that mutes a Discord user with the specified reason. + /// + /// The user to mute. + /// The duration for this mute. The user will be automatically unmuted after this duration. + /// + /// The reason for this mute. Must be encoded with when passed to + /// . + /// + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was muted and vice-versa. + /// + /// + [Command("mute", "мут")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ModerateMembers)] + [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] + [Description("Mute member")] + public async Task MuteUserAsync( + [Description("Member to mute")] IUser target, + [Description("Mute reason")] string reason, + [Description("Mute duration")] + TimeSpan duration) { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + // The current user's avatar is used when sending error messages + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); + if (!memberResult.IsSuccess) { + var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser) + .WithColour(ColorsList.Red).Build(); + + if (!embed.IsDefined(out var alreadyBuilt)) + return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken); + } + + var interactionResult + = await _utility.CheckInteractionsAsync( + guildId.Value, userId.Value, target.ID, "Mute", CancellationToken); + if (!interactionResult.IsSuccess) + return Result.FromError(interactionResult); + + var data = await _dataService.GetData(guildId.Value, CancellationToken); + var cfg = data.Configuration; + Messages.Culture = data.Culture; + + Result responseEmbed; + if (interactionResult.Entity is not null) { + responseEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) + .WithColour(ColorsList.Red).Build(); + } else { + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var until = DateTimeOffset.UtcNow.Add(duration); // >:) + var muteResult = await _guildApi.ModifyGuildMemberAsync( + guildId.Value, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(), + communicationDisabledUntil: until, ct: CancellationToken); + if (!muteResult.IsSuccess) + return Result.FromError(muteResult.Error); + + responseEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserMuted, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) + || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason)) + .Append( + string.Format( + Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))); + + var logEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserMuted, target.GetTag()), target) + .WithDescription(builder.ToString()) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + if (!logEmbed.IsDefined(out var logBuilt)) + return Result.FromError(logEmbed); + + var builtArray = new[] { logBuilt }; + // Not awaiting to reduce response time + if (cfg.PublicFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel + && cfg.PrivateFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + } + } + + if (!responseEmbed.IsDefined(out var built)) + return Result.FromError(responseEmbed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } + + /// + /// A slash command that unmutes a Discord user with the specified reason. + /// + /// The user to unmute. + /// + /// The reason for this unmute. Must be encoded with when passed to + /// . + /// + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was unmuted and vice-versa. + /// + /// + /// + [Command("unmute", "размут")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ModerateMembers)] + [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] + [Description("Unmute member")] + public async Task UnmuteUserAsync( + [Description("Member to unmute")] + IUser target, + [Description("Unmute reason")] + string reason) { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + // The current user's avatar is used when sending error messages + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); + Messages.Culture = cfg.GetCulture(); + + var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); + if (!memberResult.IsSuccess) { + var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser) + .WithColour(ColorsList.Red).Build(); + + if (!embed.IsDefined(out var alreadyBuilt)) + return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken); + } + + var interactionResult + = await _utility.CheckInteractionsAsync( + guildId.Value, userId.Value, target.ID, "Unmute", CancellationToken); + if (!interactionResult.IsSuccess) + return Result.FromError(interactionResult); + + // Needed to get the tag and avatar + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var unmuteResult = await _guildApi.ModifyGuildMemberAsync( + guildId.Value, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), + communicationDisabledUntil: null, ct: CancellationToken); + if (!unmuteResult.IsSuccess) + return Result.FromError(unmuteResult.Error); + + var responseEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserUnmuted, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) + || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + var logEmbed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserUnmuted, target.GetTag()), target) + .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Green) + .Build(); + + if (!logEmbed.IsDefined(out var logBuilt)) + return Result.FromError(logEmbed); + + var builtArray = new[] { logBuilt }; + + // Not awaiting to reduce response time + if (cfg.PublicFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel + && cfg.PrivateFeedbackChannel != channelId.Value) + _ = _channelApi.CreateMessageAsync( + cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + ct: CancellationToken); + } + + if (!responseEmbed.IsDefined(out var built)) + return Result.FromError(responseEmbed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } +} diff --git a/Commands/PingCommand.cs b/Commands/PingCommand.cs deleted file mode 100644 index 1678046..0000000 --- a/Commands/PingCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Boyfriend.Commands; - -public sealed class PingCommand : ICommand { - public string[] Aliases { get; } = { "ping", "latency", "pong", "пинг", "задержка", "понг" }; - - public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - var builder = Boyfriend.StringBuilder; - - builder.Append(Utils.GetBeep()) - .Append( - Math.Round(Math.Abs(DateTimeOffset.UtcNow.Subtract(cmd.Context.Message.Timestamp).TotalMilliseconds))) - .Append(Messages.Milliseconds); - - cmd.Reply(builder.ToString(), ReplyEmojis.Ping); - builder.Clear(); - - return Task.CompletedTask; - } -} diff --git a/Commands/PingCommandGroup.cs b/Commands/PingCommandGroup.cs new file mode 100644 index 0000000..45d27e2 --- /dev/null +++ b/Commands/PingCommandGroup.cs @@ -0,0 +1,79 @@ +using System.ComponentModel; +using Boyfriend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend.Commands; + +/// +/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping +/// +public class PingCommandGroup : CommandGroup { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly DiscordGatewayClient _client; + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestUserAPI _userApi; + + public PingCommandGroup( + IDiscordRestChannelAPI channelApi, ICommandContext context, DiscordGatewayClient client, + GuildDataService dataService, FeedbackService feedbackService, IDiscordRestUserAPI userApi) { + _channelApi = channelApi; + _context = context; + _client = client; + _dataService = dataService; + _feedbackService = feedbackService; + _userApi = userApi; + } + + /// + /// A slash command that shows time taken for the gateway to respond to the last heartbeat. + /// + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("ping", "пинг")] + [Description("Get bot latency")] + public async Task SendPingAsync() { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); + Messages.Culture = cfg.GetCulture(); + + var latency = _client.Latency.TotalMilliseconds; + if (latency is 0) { + // No heartbeat has occurred, estimate latency from local time and "Boyfriend is thinking..." message + var lastMessageResult = await _channelApi.GetChannelMessagesAsync( + channelId.Value, limit: 1, ct: CancellationToken); + if (!lastMessageResult.IsDefined(out var lastMessage)) + return Result.FromError(lastMessageResult); + latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds; + } + + var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser) + .WithTitle($"Beep{Random.Shared.Next(1, 4)}".Localized()) + .WithDescription($"{latency:F0}{Messages.Milliseconds}") + .WithColour(latency < 250 ? ColorsList.Green : latency < 500 ? ColorsList.Yellow : ColorsList.Red) + .WithCurrentTimestamp() + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } +} diff --git a/Commands/RemindCommand.cs b/Commands/RemindCommand.cs deleted file mode 100644 index 1d58763..0000000 --- a/Commands/RemindCommand.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Boyfriend.Data; - -namespace Boyfriend.Commands; - -public sealed class RemindCommand : ICommand { - public string[] Aliases { get; } = { "remind", "reminder", "remindme", "напомни", "напомнить", "напоминание" }; - - public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - // TODO: actually make this good - var remindIn = CommandProcessor.GetTimeSpan(args, 0); - if (remindIn.TotalSeconds < 1) { - cmd.Reply(Messages.InvalidRemindIn, ReplyEmojis.InvalidArgument); - return Task.CompletedTask; - } - - var reminderText = cmd.GetRemaining(cleanArgs, 1, "ReminderText"); - if (reminderText is not null) { - var reminderOffset = DateTimeOffset.UtcNow.Add(remindIn); - GuildData.Get(cmd.Context.Guild).MemberData[cmd.Context.User.Id].Reminders.Add( - new Reminder { - RemindAt = reminderOffset, - ReminderText = reminderText, - ReminderChannel = cmd.Context.Channel.Id - }); - - cmd.ConfigWriteScheduled = true; - - var feedback = string.Format(Messages.FeedbackReminderAdded, reminderOffset.ToUnixTimeSeconds().ToString()); - cmd.Reply(feedback, ReplyEmojis.Reminder); - } - - return Task.CompletedTask; - } -} diff --git a/Commands/RemindCommandGroup.cs b/Commands/RemindCommandGroup.cs new file mode 100644 index 0000000..5ee7a8c --- /dev/null +++ b/Commands/RemindCommandGroup.cs @@ -0,0 +1,66 @@ +using System.ComponentModel; +using Boyfriend.Data; +using Boyfriend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend.Commands; + +/// +/// Handles the command to manage reminders: /remind +/// +public class RemindCommandGroup : CommandGroup { + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestUserAPI _userApi; + + public RemindCommandGroup( + ICommandContext context, GuildDataService dataService, FeedbackService feedbackService, + IDiscordRestUserAPI userApi) { + _context = context; + _dataService = dataService; + _feedbackService = feedbackService; + _userApi = userApi; + } + + [Command("remind")] + [Description("Create a reminder")] + public async Task AddReminderAsync(TimeSpan duration, string text) { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var remindAt = DateTimeOffset.UtcNow.Add(duration); + + (await _dataService.GetMemberData(guildId.Value, userId.Value, CancellationToken)).Reminders.Add( + new Reminder { + RemindAt = remindAt, + Channel = channelId.Value, + Text = text + }); + + var embed = new EmbedBuilder().WithSmallTitle(string.Format(Messages.ReminderCreated, user.GetTag()), user) + .WithDescription(string.Format(Messages.DescriptionReminderCreated, Markdown.Timestamp(remindAt))) + .WithColour(ColorsList.Green) + .Build(); + + if (!embed.IsDefined(out var built)) + return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } +} diff --git a/Commands/SettingsCommand.cs b/Commands/SettingsCommand.cs deleted file mode 100644 index b987394..0000000 --- a/Commands/SettingsCommand.cs +++ /dev/null @@ -1,164 +0,0 @@ -using Boyfriend.Data; -using Discord; - -namespace Boyfriend.Commands; - -public sealed class SettingsCommand : ICommand { - public string[] Aliases { get; } = { "settings", "config", "настройки", "конфиг" }; - - public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - if (!cmd.HasPermission(GuildPermission.ManageGuild)) return Task.CompletedTask; - - var guild = cmd.Context.Guild; - var data = GuildData.Get(guild); - var config = data.Preferences; - - if (args.Length is 0) { - var currentSettings = Boyfriend.StringBuilder.AppendLine(Messages.CurrentSettings); - - foreach (var setting in GuildData.DefaultPreferences) { - var format = "{0}"; - var currentValue = config[setting.Key] is "default" - ? Messages.DefaultWelcomeMessage - : config[setting.Key]; - - if (setting.Key.EndsWith("Channel")) { - if (guild.GetTextChannel(ulong.Parse(currentValue)) is not null) format = "<#{0}>"; - else currentValue = Messages.ChannelNotSpecified; - } else if (setting.Key.EndsWith("Role")) { - if (guild.GetRole(ulong.Parse(currentValue)) is not null) format = "<@&{0}>"; - else currentValue = Messages.RoleNotSpecified; - } else { - if (!IsBool(currentValue)) format = Utils.Wrap("{0}")!; - else currentValue = YesOrNo(currentValue is "true"); - } - - currentSettings.Append($"{Utils.GetMessage($"Settings{setting.Key}")} (`{setting.Key}`): ") - .AppendFormat(format, currentValue).AppendLine(); - } - - cmd.Reply(currentSettings.ToString(), ReplyEmojis.SettingsList); - currentSettings.Clear(); - return Task.CompletedTask; - } - - var selectedSetting = args[0].ToLower(); - - var exists = false; - foreach (var setting in GuildData.DefaultPreferences.Keys.Where(x => x.ToLower() == selectedSetting)) { - selectedSetting = setting; - exists = true; - break; - } - - if (!exists) { - cmd.Reply(Messages.SettingDoesntExist, ReplyEmojis.Error); - return Task.CompletedTask; - } - - string? value; - - if (args.Length >= 2) { - value = cmd.GetRemaining(args, 1, "Setting"); - if (value is null) return Task.CompletedTask; - if (selectedSetting is "EventStartedReceivers") { - value = value.Replace(" ", "").ToLower(); - if (value.StartsWith(",") - || value.Count(x => x is ',') > 1 - || (!value.Contains("interested") && !value.Contains("users") && !value.Contains("role"))) { - cmd.Reply(Messages.InvalidSettingValue, ReplyEmojis.Error); - return Task.CompletedTask; - } - } - } else { value = "reset"; } - - if (IsBool(GuildData.DefaultPreferences[selectedSetting]) && !IsBool(value)) { - value = value switch { - "y" or "yes" or "д" or "да" => "true", "n" or "no" or "н" or "нет" => "false", _ => value - }; - if (!IsBool(value)) { - cmd.Reply(Messages.InvalidSettingValue, ReplyEmojis.Error); - return Task.CompletedTask; - } - } - - var localizedSelectedSetting = Utils.GetMessage($"Settings{selectedSetting}"); - - var mention = Utils.ParseMention(value); - if (mention is not 0 && selectedSetting is not "WelcomeMessage") value = mention.ToString(); - - var formatting = Utils.Wrap("{0}")!; - if (selectedSetting is not "WelcomeMessage") { - if (selectedSetting.EndsWith("Channel")) formatting = "<#{0}>"; - if (selectedSetting.EndsWith("Role")) formatting = "<@&{0}>"; - } - - var formattedValue = selectedSetting switch { - "WelcomeMessage" => Utils.Wrap(Messages.DefaultWelcomeMessage), - "EventStartedReceivers" => Utils.Wrap(GuildData.DefaultPreferences[selectedSetting])!, - _ => value is "reset" or "default" ? Messages.SettingNotDefined - : IsBool(value) ? YesOrNo(value is "true") - : string.Format(formatting, value) - }; - - if (value is "reset" or "default") { - config[selectedSetting] = GuildData.DefaultPreferences[selectedSetting]; - } else { - if (value == config[selectedSetting]) { - cmd.Reply( - string.Format(Messages.SettingsNothingChanged, localizedSelectedSetting, formattedValue), - ReplyEmojis.Error); - return Task.CompletedTask; - } - - if (selectedSetting is "Lang" && !Utils.CultureInfoCache.ContainsKey(value)) { - var langNotSupported = Boyfriend.StringBuilder.Append($"{Messages.LanguageNotSupported} "); - foreach (var lang in Utils.CultureInfoCache) langNotSupported.Append($"`{lang.Key}`, "); - langNotSupported.Remove(langNotSupported.Length - 2, 2); - cmd.Reply(langNotSupported.ToString(), ReplyEmojis.Error); - langNotSupported.Clear(); - return Task.CompletedTask; - } - - if (selectedSetting.EndsWith("Channel") && guild.GetTextChannel(mention) is null) { - cmd.Reply(Messages.InvalidChannel, ReplyEmojis.Error); - return Task.CompletedTask; - } - - if (selectedSetting.EndsWith("Role") && guild.GetRole(mention) is null) { - cmd.Reply(Messages.InvalidRole, ReplyEmojis.Error); - return Task.CompletedTask; - } - - if (selectedSetting.EndsWith("Offset") && !int.TryParse(value, out _)) { - cmd.Reply(Messages.InvalidSettingValue, ReplyEmojis.Error); - return Task.CompletedTask; - } - - if (selectedSetting is "MuteRole") - data.MuteRole = guild.GetRole(mention); - - config[selectedSetting] = value; - } - - if (selectedSetting is "Lang") { - Utils.SetCurrentLanguage(guild); - localizedSelectedSetting = Utils.GetMessage($"Settings{selectedSetting}"); - } - - cmd.ConfigWriteScheduled = true; - - var replyFormat = string.Format(Messages.FeedbackSettingsUpdated, localizedSelectedSetting, formattedValue); - cmd.Reply(replyFormat, ReplyEmojis.SettingsSet); - cmd.Audit(replyFormat, false); - return Task.CompletedTask; - } - - private static string YesOrNo(bool isYes) { - return isYes ? Messages.Yes : Messages.No; - } - - private static bool IsBool(string value) { - return value is "true" or "false"; - } -} diff --git a/Commands/SettingsCommandGroup.cs b/Commands/SettingsCommandGroup.cs new file mode 100644 index 0000000..7e4763f --- /dev/null +++ b/Commands/SettingsCommandGroup.cs @@ -0,0 +1,158 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text; +using Boyfriend.Data; +using Boyfriend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Messages; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend.Commands; + +/// +/// Handles the commands to list and modify per-guild settings: /settings and /settings list. +/// +public class SettingsCommandGroup : CommandGroup { + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestUserAPI _userApi; + + public SettingsCommandGroup( + ICommandContext context, GuildDataService dataService, + FeedbackService feedbackService, IDiscordRestUserAPI userApi) { + _context = context; + _dataService = dataService; + _feedbackService = feedbackService; + _userApi = userApi; + } + + /// + /// A slash command that lists current per-guild settings. + /// + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("settings list")] + [Description("Shows settings list for this server")] + [SuppressInteractionResponse(suppress: true)] + public async Task SendSettingsListAsync() { + if (!_context.TryGetContextIDs(out var guildId, out _, out _)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); + Messages.Culture = cfg.GetCulture(); + + var builder = new StringBuilder(); + + foreach (var setting in typeof(GuildConfiguration).GetProperties()) { + builder.Append(Markdown.InlineCode(setting.Name)) + .Append(": "); + var something = setting.GetValue(cfg); + if (something!.GetType() == typeof(List)) { + var list = (something as List); + builder.AppendLine(string.Join(", ", list!.Select(v => Markdown.InlineCode(v.ToString())))); + } else { builder.AppendLine(Markdown.InlineCode(something.ToString()!)); } + } + + var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, currentUser) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Default) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync( + built, ct: CancellationToken, options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral)); + } + + /// + /// A slash command that modifies per-guild settings. + /// + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("settings")] + [Description("Change settings for this server")] + public async Task EditSettingsAsync( + [Description("настройка")] string setting, + [Description("значение")] string value) { + if (!_context.TryGetContextIDs(out var guildId, out _, out _)) + return Result.FromError( + new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); + + var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + + var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); + Messages.Culture = cfg.GetCulture(); + + PropertyInfo? property = null; + + try { + foreach (var prop in typeof(GuildConfiguration).GetProperties()) + if (string.Equals(setting, prop.Name, StringComparison.CurrentCultureIgnoreCase)) + property = prop; + if (property == null || !property.CanWrite) + throw new ApplicationException(Messages.SettingDoesntExist); + var type = property.PropertyType; + + if (value is "reset" or "default") { property.SetValue(cfg, null); } else if (type == typeof(string)) { + if (setting == "language" && value is not ("ru" or "en" or "mctaylors-ru")) + throw new ApplicationException(Messages.LanguageNotSupported); + property.SetValue(cfg, value); + } else { + try { + if (type == typeof(bool)) + property.SetValue(cfg, Convert.ToBoolean(value)); + + if (type == typeof(ulong)) { + var id = Convert.ToUInt64(value); + + property.SetValue(cfg, id); + } + } catch (Exception e) when (e is FormatException or OverflowException) { + throw new ApplicationException(Messages.InvalidSettingValue); + } + } + } catch (Exception e) { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, currentUser) + .WithDescription(e.Message) + .WithColour(ColorsList.Red) + .Build(); + if (!failedEmbed.IsDefined(out var failedBuilt)) return Result.FromError(failedEmbed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(failedBuilt, ct: CancellationToken); + } + + var builder = new StringBuilder(); + + builder.Append(Markdown.InlineCode(setting)) + .Append($" {Messages.SettingIsNow} ") + .Append(Markdown.InlineCode(value)); + + var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingSuccessfulyChanged, currentUser) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Green) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken); + } +} diff --git a/Commands/UnbanCommand.cs b/Commands/UnbanCommand.cs deleted file mode 100644 index fb8040e..0000000 --- a/Commands/UnbanCommand.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Discord; - -namespace Boyfriend.Commands; - -public sealed class UnbanCommand : ICommand { - public string[] Aliases { get; } = { "unban", "pardon", "разбан" }; - - public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - if (!cmd.HasPermission(GuildPermission.BanMembers)) return; - - var id = cmd.GetBan(args, 0); - if (id is null) return; - var reason = cmd.GetRemaining(args, 1, "UnbanReason"); - if (reason is not null) await UnbanUserAsync(cmd, id.Value, reason); - } - - private static async Task UnbanUserAsync(CommandProcessor cmd, ulong id, string reason) { - var requestOptions = Utils.GetRequestOptions($"({cmd.Context.User}) {reason}"); - await cmd.Context.Guild.RemoveBanAsync(id, requestOptions); - - var feedback = string.Format(Messages.FeedbackUserUnbanned, $"<@{id.ToString()}>", Utils.Wrap(reason)); - cmd.Reply(feedback); - cmd.Audit(feedback); - } -} diff --git a/Commands/UnmuteCommand.cs b/Commands/UnmuteCommand.cs deleted file mode 100644 index 6310c1d..0000000 --- a/Commands/UnmuteCommand.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Boyfriend.Data; -using Discord; -using Discord.WebSocket; - -namespace Boyfriend.Commands; - -public sealed class UnmuteCommand : ICommand { - public string[] Aliases { get; } = { "unmute", "размут" }; - - public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) { - if (!cmd.HasPermission(GuildPermission.ModerateMembers)) return; - - var toUnmute = cmd.GetMember(args, 0); - if (toUnmute is null) return; - var reason = cmd.GetRemaining(args, 1, "UnmuteReason"); - if (reason is not null && cmd.CanInteractWith(toUnmute, "Unmute")) - await UnmuteMemberAsync(cmd, toUnmute, reason); - } - - private static async Task UnmuteMemberAsync(CommandProcessor cmd, SocketGuildUser toUnmute, - string reason) { - var isMuted = await Utils.UnmuteMemberAsync(GuildData.Get(cmd.Context.Guild), cmd.Context.User.ToString(), - toUnmute, reason); - - if (!isMuted) { - cmd.Reply(Messages.MemberNotMuted, ReplyEmojis.Error); - return; - } - - cmd.ConfigWriteScheduled = true; - - var feedback = string.Format(Messages.FeedbackMemberUnmuted, toUnmute.Mention, Utils.Wrap(reason)); - cmd.Reply(feedback, ReplyEmojis.Unmuted); - cmd.Audit(feedback); - } -} diff --git a/Data/GuildConfiguration.cs b/Data/GuildConfiguration.cs new file mode 100644 index 0000000..440e2b7 --- /dev/null +++ b/Data/GuildConfiguration.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using Remora.Discord.API.Abstractions.Objects; + +namespace Boyfriend.Data; + +/// +/// Stores per-guild settings that can be set by a member +/// with using the /settings command +/// +public class GuildConfiguration { + /// + /// Represents a scheduled event notification receiver. + /// + /// + /// Used to selectively mention guild members when a scheduled event has started or is about to start. + /// + public enum NotificationReceiver { + Interested, + Role + } + + public static readonly Dictionary CultureInfoCache = new() { + { "en", new CultureInfo("en-US") }, + { "ru", new CultureInfo("ru-RU") }, + { "mctaylors-ru", new CultureInfo("tt-RU") } + }; + + public string Language { get; set; } = "en"; + + /// + /// Controls what message should be sent in when a new member joins the server. + /// + /// + /// + /// No message will be sent if set to "off", "disable" or "disabled". + /// will be sent if set to "default" or "reset" + /// + /// + /// + public string WelcomeMessage { get; set; } = "default"; + + /// + /// Controls whether or not the message should be sent + /// in on startup. + /// + /// + public bool ReceiveStartupMessages { get; set; } + + public bool RemoveRolesOnMute { get; set; } + + /// + /// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back. + /// + /// Roles will not be returned if the member left the guild because of /ban or /kick. + public bool ReturnRolesOnRejoin { get; set; } + + public bool AutoStartEvents { get; set; } + + /// + /// Controls what channel should all public messages be sent to. + /// + public ulong PublicFeedbackChannel { get; set; } + + /// + /// Controls what channel should all private, moderator-only messages be sent to. + /// + public ulong PrivateFeedbackChannel { get; set; } + + public ulong EventNotificationChannel { get; set; } + public ulong DefaultRole { get; set; } + public ulong MuteRole { get; set; } + public ulong EventNotificationRole { get; set; } + + /// + /// Controls what guild members should be mentioned when a scheduled event has started or is about to start. + /// + /// + public List EventStartedReceivers { get; set; } + = new() { NotificationReceiver.Interested, NotificationReceiver.Role }; + + /// + /// Controls the amount of time before a scheduled event to send a reminder in . + /// + public TimeSpan EventEarlyNotificationOffset { get; set; } = TimeSpan.Zero; + + // Do not convert this to a property, else serialization will be attempted + public CultureInfo GetCulture() { + return CultureInfoCache[Language]; + } +} diff --git a/Data/GuildData.cs b/Data/GuildData.cs index 8403e2c..992adc0 100644 --- a/Data/GuildData.cs +++ b/Data/GuildData.cs @@ -1,142 +1,41 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using Discord; -using Discord.WebSocket; +using System.Globalization; +using Remora.Rest.Core; namespace Boyfriend.Data; -public record GuildData { - public static readonly Dictionary DefaultPreferences = new() { - { "Prefix", "!" }, - { "Lang", "en" }, - { "ReceiveStartupMessages", "false" }, - { "WelcomeMessage", "default" }, - { "SendWelcomeMessages", "true" }, - { "PublicFeedbackChannel", "0" }, - { "PrivateFeedbackChannel", "0" }, - { "StarterRole", "0" }, - { "MuteRole", "0" }, - { "RemoveRolesOnMute", "false" }, - { "ReturnRolesOnRejoin", "false" }, - { "EventStartedReceivers", "interested,role" }, - { "EventNotificationRole", "0" }, - { "EventNotificationChannel", "0" }, - { "EventEarlyNotificationOffset", "0" }, - { "AutoStartEvents", "false" } - }; - - public static readonly ConcurrentDictionary GuildDataDictionary = new(); - - private static readonly JsonSerializerOptions Options = new() { - IncludeFields = true, - WriteIndented = true - }; - - private readonly string _configurationFile; - - private readonly ulong _id; - - public readonly List EarlyNotifications = new(); +/// +/// Stores information about a guild. This information is not accessible via the Discord API. +/// +/// This information is stored on disk as a JSON file. +public class GuildData { + public readonly GuildConfiguration Configuration; + public readonly string ConfigurationPath; public readonly Dictionary MemberData; + public readonly string MemberDataPath; - public readonly Dictionary Preferences; + public readonly Dictionary ScheduledEvents; + public readonly string ScheduledEventsPath; - private SocketRole? _cachedMuteRole; - - [SuppressMessage("Performance", "CA1853:Unnecessary call to \'Dictionary.ContainsKey(key)\'")] - // https://github.com/dotnet/roslyn-analyzers/issues/6377 - private GuildData(SocketGuild guild) { - var downloaderTask = guild.DownloadUsersAsync(); - _id = guild.Id; - var idString = $"{_id}"; - var memberDataDir = $"{_id}/MemberData"; - _configurationFile = $"{_id}/Configuration.json"; - if (!Directory.Exists(idString)) Directory.CreateDirectory(idString); - if (!Directory.Exists(memberDataDir)) Directory.CreateDirectory(memberDataDir); - if (!File.Exists(_configurationFile)) File.WriteAllText(_configurationFile, "{}"); - Preferences - = JsonSerializer.Deserialize>(File.ReadAllText(_configurationFile)) - ?? new Dictionary(); - - if (Preferences.Keys.Count < DefaultPreferences.Keys.Count) - foreach (var key in DefaultPreferences.Keys.Where(key => !Preferences.ContainsKey(key))) - Preferences.Add(key, DefaultPreferences[key]); - if (Preferences.Keys.Count > DefaultPreferences.Keys.Count) - foreach (var key in Preferences.Keys.Where(key => !DefaultPreferences.ContainsKey(key))) - Preferences.Remove(key); - Preferences.TrimExcess(); - - MemberData = new Dictionary(); - foreach (var data in Directory.GetFiles(memberDataDir)) { - var deserialised - = JsonSerializer.Deserialize(File.ReadAllText(data), Options); - MemberData.Add(deserialised!.Id, deserialised); - } - - downloaderTask.Wait(); - foreach (var member in guild.Users) { - if (MemberData.TryGetValue(member.Id, out var memberData)) { - if (!memberData.IsInGuild - && DateTimeOffset.UtcNow.ToUnixTimeSeconds() - - Math.Max( - memberData.LeftAt.Last().ToUnixTimeSeconds(), - memberData.BannedUntil?.ToUnixTimeSeconds() ?? 0) - > 60 * 60 * 24 * 30) { - File.Delete($"{_id}/MemberData/{memberData.Id}.json"); - MemberData.Remove(memberData.Id); - } - - if (memberData.MutedUntil is null) { - memberData.Roles = ((IGuildUser)member).RoleIds.ToList(); - memberData.Roles.Remove(guild.Id); - } - - continue; - } - - MemberData.Add(member.Id, new MemberData(member)); - } - - MemberData.TrimExcess(); + public GuildData( + GuildConfiguration configuration, string configurationPath, + Dictionary scheduledEvents, string scheduledEventsPath, + Dictionary memberData, string memberDataPath) { + Configuration = configuration; + ConfigurationPath = configurationPath; + ScheduledEvents = scheduledEvents; + ScheduledEventsPath = scheduledEventsPath; + MemberData = memberData; + MemberDataPath = memberDataPath; } - public SocketRole? MuteRole { - get { - if (Preferences["MuteRole"] is "0") return null; - return _cachedMuteRole ??= Boyfriend.Client.GetGuild(_id).Roles - .Single(x => x.Id == ulong.Parse(Preferences["MuteRole"])); - } - set => _cachedMuteRole = value; - } + public CultureInfo Culture => Configuration.GetCulture(); - public SocketTextChannel? PublicFeedbackChannel - => Boyfriend.Client.GetGuild(_id) - .GetTextChannel( - ulong.Parse(Preferences["PublicFeedbackChannel"])); + public MemberData GetMemberData(Snowflake userId) { + if (MemberData.TryGetValue(userId.Value, out var existing)) return existing; - public SocketTextChannel? PrivateFeedbackChannel => Boyfriend.Client.GetGuild(_id) - .GetTextChannel( - ulong.Parse( - Preferences["PrivateFeedbackChannel"])); - - public static GuildData Get(SocketGuild guild) { - if (GuildDataDictionary.TryGetValue(guild.Id, out var stored)) return stored; - var newData = new GuildData(guild); - while (!GuildDataDictionary.ContainsKey(guild.Id)) GuildDataDictionary.TryAdd(guild.Id, newData); + var newData = new MemberData(userId.Value, null); + MemberData.Add(userId.Value, newData); return newData; } - - public async Task Save(bool saveMemberData) { - Preferences.TrimExcess(); - await File.WriteAllTextAsync( - _configurationFile, - JsonSerializer.Serialize(Preferences)); - if (saveMemberData) - foreach (var data in MemberData.Values) - await File.WriteAllTextAsync( - $"{_id}/MemberData/{data.Id}.json", - JsonSerializer.Serialize(data, Options)); - } } diff --git a/Data/MemberData.cs b/Data/MemberData.cs index 137375a..72cbdec 100644 --- a/Data/MemberData.cs +++ b/Data/MemberData.cs @@ -1,38 +1,18 @@ -using System.Text.Json.Serialization; -using Discord; +using Remora.Rest.Core; namespace Boyfriend.Data; -public record MemberData { - public DateTimeOffset? BannedUntil; - public ulong Id; - public bool IsInGuild; - public List JoinedAt; - public List LeftAt; - public DateTimeOffset? MutedUntil; - public List Reminders; - public List Roles; - - [JsonConstructor] - public MemberData(DateTimeOffset? bannedUntil, ulong id, bool isInGuild, List joinedAt, - List leftAt, DateTimeOffset? mutedUntil, List reminders, List roles) { - BannedUntil = bannedUntil; +/// +/// Stores information about a member +/// +public class MemberData { + public MemberData(ulong id, DateTimeOffset? bannedUntil) { Id = id; - IsInGuild = isInGuild; - JoinedAt = joinedAt; - LeftAt = leftAt; - MutedUntil = mutedUntil; - Reminders = reminders; - Roles = roles; + BannedUntil = bannedUntil; } - public MemberData(IGuildUser user) { - Id = user.Id; - IsInGuild = true; - JoinedAt = new List { user.JoinedAt!.Value }; - LeftAt = new List(); - Roles = user.RoleIds.ToList(); - Roles.Remove(user.Guild.Id); - Reminders = new List(); - } + public ulong Id { get; } + public DateTimeOffset? BannedUntil { get; set; } + public List Roles { get; set; } = new(); + public List Reminders { get; } = new(); } diff --git a/Data/Reminder.cs b/Data/Reminder.cs index c64ebbd..1d0410c 100644 --- a/Data/Reminder.cs +++ b/Data/Reminder.cs @@ -1,7 +1,9 @@ -namespace Boyfriend.Data; +using Remora.Rest.Core; + +namespace Boyfriend.Data; public struct Reminder { public DateTimeOffset RemindAt; - public string ReminderText; - public ulong ReminderChannel; + public string Text; + public Snowflake Channel; } diff --git a/Data/ScheduledEventData.cs b/Data/ScheduledEventData.cs new file mode 100644 index 0000000..661eed0 --- /dev/null +++ b/Data/ScheduledEventData.cs @@ -0,0 +1,17 @@ +using Remora.Discord.API.Abstractions.Objects; + +namespace Boyfriend.Data; + +/// +/// Stores information about scheduled events. This information is not provided by the Discord API. +/// +/// This information is stored on disk as a JSON file. +public class ScheduledEventData { + public ScheduledEventData(GuildScheduledEventStatus status) { + Status = status; + } + + public bool EarlyNotificationSent { get; set; } + public DateTimeOffset? ActualStartTime { get; set; } + public GuildScheduledEventStatus Status { get; set; } +} diff --git a/EventHandler.cs b/EventHandler.cs deleted file mode 100644 index 017c604..0000000 --- a/EventHandler.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Diagnostics; -using Boyfriend.Data; -using Discord; -using Discord.Rest; -using Discord.WebSocket; - -namespace Boyfriend; - -public static class EventHandler { - private static readonly DiscordSocketClient Client = Boyfriend.Client; - private static bool _sendReadyMessages = true; - - public static void InitEvents() { - Client.Ready += ReadyEvent; - Client.MessageDeleted += MessageDeletedEvent; - Client.MessageReceived += MessageReceivedEvent; - Client.MessageUpdated += MessageUpdatedEvent; - Client.UserJoined += UserJoinedEvent; - Client.UserLeft += UserLeftEvent; - Client.GuildMemberUpdated += MemberRolesUpdatedEvent; - Client.GuildScheduledEventCreated += ScheduledEventCreatedEvent; - Client.GuildScheduledEventCancelled += ScheduledEventCancelledEvent; - Client.GuildScheduledEventStarted += ScheduledEventStartedEvent; - Client.GuildScheduledEventCompleted += ScheduledEventCompletedEvent; - } - - private static Task MemberRolesUpdatedEvent(Cacheable oldUser, SocketGuildUser newUser) { - var data = GuildData.Get(newUser.Guild).MemberData[newUser.Id]; - if (data.MutedUntil is null) { - data.Roles = ((IGuildUser)newUser).RoleIds.ToList(); - data.Roles.Remove(newUser.Guild.Id); - } - - return Task.CompletedTask; - } - - private static Task ReadyEvent() { - if (!_sendReadyMessages) return Task.CompletedTask; - var i = Random.Shared.Next(3); - - foreach (var guild in Client.Guilds) { - Boyfriend.Log(new LogMessage(LogSeverity.Info, nameof(EventHandler), $"Guild \"{guild.Name}\" is READY")); - var data = GuildData.Get(guild); - var config = data.Preferences; - var channel = data.PrivateFeedbackChannel; - if (config["ReceiveStartupMessages"] is not "true" || channel is null) continue; - - Utils.SetCurrentLanguage(guild); - _ = channel.SendMessageAsync(string.Format(Messages.Ready, Utils.GetBeep(i))); - } - - _sendReadyMessages = false; - return Task.CompletedTask; - } - - private static async Task MessageDeletedEvent( - Cacheable message, - Cacheable channel) { - var msg = message.Value; - if (channel.Value is not SocketGuildChannel gChannel - || msg is null or ISystemMessage - || msg.Author.IsBot) return; - - var guild = gChannel.Guild; - - Utils.SetCurrentLanguage(guild); - - var mention = msg.Author.Mention; - - await Task.Delay(500); - - var auditLogEnumerator - = (await guild.GetAuditLogsAsync(1, actionType: ActionType.MessageDeleted).FlattenAsync()).GetEnumerator(); - if (auditLogEnumerator.MoveNext()) { - var auditLogEntry = auditLogEnumerator.Current!; - if (auditLogEntry.CreatedAt >= DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1)) - && auditLogEntry.Data is MessageDeleteAuditLogData data - && msg.Author.Id == data.Target.Id) - mention = auditLogEntry.User.Mention; - } - - auditLogEnumerator.Dispose(); - - await Utils.SendFeedbackAsync( - string.Format( - Messages.CachedMessageDeleted, msg.Author.Mention, - Utils.MentionChannel(channel.Id), - Utils.Wrap(msg.CleanContent)), guild, mention); - } - - private static Task MessageReceivedEvent(IDeletable messageParam) { - if (messageParam is not SocketUserMessage message || message.Author.IsWebhook) return Task.CompletedTask; - - _ = message.CleanContent.ToLower() switch { - "whoami" => message.ReplyAsync("`nobody`"), - "сука !!" => message.ReplyAsync("`root`"), - "воооо" => message.ReplyAsync("`removing /...`"), - "пон" => message.ReplyAsync( - "https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg"), - "++++" => message.ReplyAsync("#"), - _ => new CommandProcessor(message).HandleCommandAsync() - }; - return Task.CompletedTask; - } - - private static async Task MessageUpdatedEvent( - Cacheable messageCached, IMessage messageSocket, - ISocketMessageChannel channel) { - var msg = messageCached.Value; - if (channel is not SocketGuildChannel gChannel - || msg is null or ISystemMessage - || msg.CleanContent == messageSocket.CleanContent - || msg.Author.IsBot) return; - - var guild = gChannel.Guild; - Utils.SetCurrentLanguage(guild); - - var isLimitedSpace = msg.CleanContent.Length + messageSocket.CleanContent.Length < 1940; - - await Utils.SendFeedbackAsync( - string.Format( - Messages.CachedMessageEdited, Utils.MentionChannel(channel.Id), - Utils.Wrap(msg.CleanContent, isLimitedSpace), Utils.Wrap(messageSocket.CleanContent, isLimitedSpace)), - guild, msg.Author.Mention); - } - - private static async Task UserJoinedEvent(SocketGuildUser user) { - var guild = user.Guild; - var data = GuildData.Get(guild); - var config = data.Preferences; - Utils.SetCurrentLanguage(guild); - - if (config["SendWelcomeMessages"] is "true" && data.PublicFeedbackChannel is not null) - await Utils.SilentSendAsync( - data.PublicFeedbackChannel, - string.Format( - config["WelcomeMessage"] is "default" - ? Messages.DefaultWelcomeMessage - : config["WelcomeMessage"], user.Mention, guild.Name)); - - if (!data.MemberData.ContainsKey(user.Id)) data.MemberData.Add(user.Id, new MemberData(user)); - var memberData = data.MemberData[user.Id]; - memberData.IsInGuild = true; - memberData.BannedUntil = null; - if (memberData.LeftAt.Count > 0) { - if (memberData.JoinedAt.Contains(user.JoinedAt!.Value)) - throw new UnreachableException(); - memberData.JoinedAt.Add(user.JoinedAt!.Value); - } - - if (DateTimeOffset.UtcNow < memberData.MutedUntil) { - await user.AddRoleAsync(data.MuteRole); - if (config["RemoveRolesOnMute"] is "false" && config["ReturnRolesOnRejoin"] is "true") - await user.AddRolesAsync(memberData.Roles); - } else if (config["ReturnRolesOnRejoin"] is "true") { await user.AddRolesAsync(memberData.Roles); } - } - - private static Task UserLeftEvent(SocketGuild guild, SocketUser user) { - var data = GuildData.Get(guild).MemberData[user.Id]; - data.IsInGuild = false; - data.LeftAt.Add(DateTimeOffset.UtcNow); - return Task.CompletedTask; - } - - private static async Task ScheduledEventCreatedEvent(SocketGuildEvent scheduledEvent) { - var guild = scheduledEvent.Guild; - var eventConfig = GuildData.Get(guild).Preferences; - var channel = Utils.GetEventNotificationChannel(guild); - Utils.SetCurrentLanguage(guild); - if (channel is null) return; - - var role = guild.GetRole(ulong.Parse(eventConfig["EventNotificationRole"])); - var mentions = role is not null - ? $"{role.Mention} {scheduledEvent.Creator.Mention}" - : $"{scheduledEvent.Creator.Mention}"; - - var location = Utils.Wrap(scheduledEvent.Location) ?? Utils.MentionChannel(scheduledEvent.Channel.Id); - var descAndLink - = $"\n{Utils.Wrap(scheduledEvent.Description)}\nhttps://discord.com/events/{guild.Id}/{scheduledEvent.Id}"; - - await Utils.SilentSendAsync( - channel, - string.Format( - Messages.EventCreated, mentions, - Utils.Wrap(scheduledEvent.Name), location, - scheduledEvent.StartTime.ToUnixTimeSeconds().ToString(), descAndLink), - true); - } - - private static async Task ScheduledEventCancelledEvent(SocketGuildEvent scheduledEvent) { - var guild = scheduledEvent.Guild; - var eventConfig = GuildData.Get(guild).Preferences; - var channel = Utils.GetEventNotificationChannel(guild); - Utils.SetCurrentLanguage(guild); - if (channel is not null) - await channel.SendMessageAsync( - string.Format( - Messages.EventCancelled, Utils.Wrap(scheduledEvent.Name), - eventConfig["FrowningFace"] is "true" ? $" {Messages.SettingsFrowningFace}" : "")); - } - - private static async Task ScheduledEventStartedEvent(SocketGuildEvent scheduledEvent) { - var guild = scheduledEvent.Guild; - var eventConfig = GuildData.Get(guild).Preferences; - var channel = Utils.GetEventNotificationChannel(guild); - Utils.SetCurrentLanguage(guild); - - if (channel is null) return; - - var receivers = eventConfig["EventStartedReceivers"]; - var role = guild.GetRole(ulong.Parse(eventConfig["EventNotificationRole"])); - var mentions = Boyfriend.StringBuilder; - - if (receivers.Contains("role") && role is not null) mentions.Append($"{role.Mention} "); - if (receivers.Contains("users") || receivers.Contains("interested")) - mentions = (await scheduledEvent.GetUsersAsync(15)) - .Where(user => role is null || !((RestGuildUser)user).RoleIds.Contains(role.Id)) - .Aggregate(mentions, (current, user) => current.Append($"{user.Mention} ")); - - await channel.SendMessageAsync( - string.Format( - Messages.EventStarted, mentions, - Utils.Wrap(scheduledEvent.Name), - Utils.Wrap(scheduledEvent.Location) ?? Utils.MentionChannel(scheduledEvent.Channel.Id))); - mentions.Clear(); - } - - private static async Task ScheduledEventCompletedEvent(SocketGuildEvent scheduledEvent) { - var guild = scheduledEvent.Guild; - var channel = Utils.GetEventNotificationChannel(guild); - Utils.SetCurrentLanguage(guild); - if (channel is not null) - await channel.SendMessageAsync( - string.Format( - Messages.EventCompleted, Utils.Wrap(scheduledEvent.Name), - Utils.GetHumanizedTimeSpan(DateTimeOffset.UtcNow.Subtract(scheduledEvent.StartTime)))); - } -} diff --git a/EventResponders.cs b/EventResponders.cs new file mode 100644 index 0000000..708bbc1 --- /dev/null +++ b/EventResponders.cs @@ -0,0 +1,335 @@ +using Boyfriend.Data; +using Boyfriend.Services; +using DiffPlex.DiffBuilder; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Caching; +using Remora.Discord.Caching.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Discord.Gateway.Responders; +using Remora.Rest.Core; +using Remora.Results; + +// ReSharper disable UnusedType.Global + +namespace Boyfriend; + +/// +/// Handles sending a message to a guild that has just initialized if that guild +/// has enabled +/// +public class GuildCreateResponder : IResponder { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly ILogger _logger; + private readonly IDiscordRestUserAPI _userApi; + + public GuildCreateResponder( + IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger logger, + IDiscordRestUserAPI userApi) { + _channelApi = channelApi; + _dataService = dataService; + _logger = logger; + _userApi = userApi; + } + + public async Task RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) { + if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild isn't IAvailableGuild + + var guild = gatewayEvent.Guild.AsT0; + _logger.LogInformation("Joined guild \"{Name}\"", guild.Name); + + var guildConfig = await _dataService.GetConfiguration(guild.ID, ct); + if (!guildConfig.ReceiveStartupMessages) + return Result.FromSuccess(); + if (guildConfig.PrivateFeedbackChannel is 0) + return Result.FromSuccess(); + + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + + Messages.Culture = guildConfig.GetCulture(); + var i = Random.Shared.Next(1, 4); + + var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser) + .WithTitle($"Beep{i}".Localized()) + .WithDescription(Messages.Ready) + .WithCurrentTimestamp() + .WithColour(ColorsList.Blue) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + guildConfig.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct); + } +} + +/// +/// Handles logging the contents of a deleted message and the user who deleted the message +/// to a guild's if one is set. +/// +public class MessageDeletedResponder : IResponder { + private readonly IDiscordRestAuditLogAPI _auditLogApi; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly IDiscordRestUserAPI _userApi; + + public MessageDeletedResponder( + IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi, + GuildDataService dataService, IDiscordRestUserAPI userApi) { + _auditLogApi = auditLogApi; + _channelApi = channelApi; + _dataService = dataService; + _userApi = userApi; + } + + public async Task RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) { + if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess(); + + var guildConfiguration = await _dataService.GetConfiguration(guildId, ct); + if (guildConfiguration.PrivateFeedbackChannel is 0) return Result.FromSuccess(); + + var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); + if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult); + if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess(); + + var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync( + guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct); + if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult); + + var auditLog = auditLogPage.AuditLogEntries.Single(); + if (!auditLog.Options.IsDefined(out var options)) + return Result.FromError(new ArgumentNullError(nameof(auditLog.Options))); + + var user = message.Author; + if (options.ChannelID == gatewayEvent.ChannelID + && DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) { + var userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct); + if (!userResult.IsDefined(out user)) return Result.FromError(userResult); + } + + Messages.Culture = guildConfiguration.GetCulture(); + + var embed = new EmbedBuilder() + .WithSmallTitle( + string.Format( + Messages.CachedMessageDeleted, + message.Author.GetTag()), message.Author) + .WithDescription( + $"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}") + .WithActionFooter(user) + .WithTimestamp(message.Timestamp) + .WithColour(ColorsList.Red) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + guildConfiguration.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, + allowedMentions: Boyfriend.NoMentions, ct: ct); + } +} + +/// +/// Handles logging the difference between an edited message's old and new content +/// to a guild's if one is set. +/// +public class MessageEditedResponder : IResponder { + private readonly CacheService _cacheService; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly IDiscordRestUserAPI _userApi; + + public MessageEditedResponder( + CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService, + IDiscordRestUserAPI userApi) { + _cacheService = cacheService; + _channelApi = channelApi; + _dataService = dataService; + _userApi = userApi; + } + + public async Task RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) { + if (!gatewayEvent.GuildID.IsDefined(out var guildId)) + return Result.FromSuccess(); + var guildConfiguration = await _dataService.GetConfiguration(guildId, ct); + if (guildConfiguration.PrivateFeedbackChannel is 0) + return Result.FromSuccess(); + if (!gatewayEvent.Content.IsDefined(out var newContent)) + return Result.FromSuccess(); + if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp)) + return Result.FromSuccess(); // The message wasn't actually edited + + if (!gatewayEvent.ChannelID.IsDefined(out var channelId)) + return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID))); + if (!gatewayEvent.ID.IsDefined(out var messageId)) + return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ID))); + + var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId); + var messageResult = await _cacheService.TryGetValueAsync( + cacheKey, ct); + if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult); + if (message.Content == newContent) return Result.FromSuccess(); + + // Custom event responders are called earlier than responders responsible for message caching + // This means that subsequent edit logs may contain the wrong content + // We can work around this by evicting the message from the cache + await _cacheService.EvictAsync(cacheKey, ct); + // However, since we evicted the message, subsequent edits won't have a cached instance to work with + // Getting the message will put it back in the cache, resolving all issues + // We don't need to await this since the result is not needed + // NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages + // NOTE: Awaiting this might not even solve this if the same responder is called asynchronously + _ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct); + + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + + var diff = InlineDiffBuilder.Diff(message.Content, newContent); + + Messages.Culture = guildConfiguration.GetCulture(); + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author) + .WithDescription($"https://discord.com/channels/{guildId}/{channelId}/{messageId}\n{diff.AsMarkdown()}") + .WithUserFooter(currentUser) + .WithTimestamp(timestamp.Value) + .WithColour(ColorsList.Yellow) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + guildConfiguration.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, + allowedMentions: Boyfriend.NoMentions, ct: ct); + } +} + +/// +/// Handles sending a guild's if one is set. +/// If is enabled, roles will be returned. +/// +/// +public class GuildMemberAddResponder : IResponder { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly IDiscordRestGuildAPI _guildApi; + + public GuildMemberAddResponder( + IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) { + _channelApi = channelApi; + _dataService = dataService; + _guildApi = guildApi; + } + + public async Task RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) { + if (!gatewayEvent.User.IsDefined(out var user)) + return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.User))); + var data = await _dataService.GetData(gatewayEvent.GuildID, ct); + var cfg = data.Configuration; + if (cfg.PublicFeedbackChannel is 0 || cfg.WelcomeMessage is "off" or "disable" or "disabled") + return Result.FromSuccess(); + if (cfg.ReturnRolesOnRejoin) { + var result = await _guildApi.ModifyGuildMemberAsync( + gatewayEvent.GuildID, user.ID, roles: data.GetMemberData(user.ID).Roles, ct: ct); + if (!result.IsSuccess) return Result.FromError(result.Error); + } + + Messages.Culture = data.Culture; + var welcomeMessage = cfg.WelcomeMessage is "default" or "reset" + ? Messages.DefaultWelcomeMessage + : cfg.WelcomeMessage; + + var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); + if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult); + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user) + .WithGuildFooter(guild) + .WithTimestamp(gatewayEvent.JoinedAt) + .WithColour(ColorsList.Green) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, + allowedMentions: Boyfriend.NoMentions, ct: ct); + } +} + +/// +/// Handles sending a notification when a scheduled event has been cancelled +/// in a guild's if one is set. +/// +public class GuildScheduledEventDeleteResponder : IResponder { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + + public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) { + _channelApi = channelApi; + _dataService = dataService; + } + + public async Task RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) { + var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct); + guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value); + + if (guildData.Configuration.EventNotificationChannel is 0) + return Result.FromSuccess(); + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name)) + .WithDescription(":(") + .WithColour(ColorsList.Red) + .WithCurrentTimestamp() + .Build(); + + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + guildData.Configuration.EventNotificationChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct); + } +} + +/// +/// Handles updating when a guild member is updated. +/// +public class GuildMemberUpdateResponder : IResponder { + private readonly GuildDataService _dataService; + + public GuildMemberUpdateResponder(GuildDataService dataService) { + _dataService = dataService; + } + + public async Task RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) { + var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct); + memberData.Roles = gatewayEvent.Roles.ToList(); + return Result.FromSuccess(); + } +} + +/// +/// Handles sending replies to easter egg messages. +/// +public class MessageCreateResponder : IResponder { + private readonly IDiscordRestChannelAPI _channelApi; + + public MessageCreateResponder(IDiscordRestChannelAPI channelApi) { + _channelApi = channelApi; + } + + public Task RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) { + _ = _channelApi.CreateMessageAsync( + gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content switch { + "whoami" => "`nobody`", + "сука !!" => "`root`", + "воооо" => "`removing /...`", + "пон" => + "https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg", + "++++" => "#", + _ => default(Optional) + }); + return Task.FromResult(Result.FromSuccess()); + } +} diff --git a/Extensions.cs b/Extensions.cs new file mode 100644 index 0000000..bb9ff20 --- /dev/null +++ b/Extensions.cs @@ -0,0 +1,189 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Text; +using DiffPlex.DiffBuilder.Model; +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Extensions; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; + +namespace Boyfriend; + +public static class Extensions { + /// + /// Adds a footer with the 's avatar and tag (@username or username#0000). + /// + /// The builder to add the footer to. + /// The user whose tag and avatar to add. + /// The builder with the added footer. + public static EmbedBuilder WithUserFooter(this EmbedBuilder builder, IUser user) { + var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256); + var avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity.AbsoluteUri + : CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri; + + return builder.WithFooter(new EmbedFooter(user.GetTag(), avatarUrl)); + } + + /// + /// Adds a footer representing that an action was performed by a . + /// + /// The builder to add the footer to. + /// The user that performed the action whose tag and avatar to use. + /// The builder with the added footer. + public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user) { + var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256); + var avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity.AbsoluteUri + : CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri; + + return builder.WithFooter( + new EmbedFooter($"{Messages.IssuedBy}:\n{user.GetTag()}", avatarUrl)); + } + + /// + /// Adds a title using the author field, making it smaller than using the title field. + /// + /// The builder to add the small title to. + /// The text of the small title. + /// The user whose avatar to use in the small title. + /// The URL that will be opened if a user clicks on the small title. + /// The builder with the added small title in the author field. + public static EmbedBuilder WithSmallTitle( + this EmbedBuilder builder, string text, IUser? avatarSource = null, string? url = default) { + Uri? avatarUrl = null; + if (avatarSource is not null) { + var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256); + + avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity + : CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity; + } + + builder.Author = new EmbedAuthorBuilder(text, url, avatarUrl?.AbsoluteUri); + return builder; + } + + /// + /// Adds a footer representing that the action was performed in the . + /// + /// The builder to add the footer to. + /// The guild whose name and icon to use. + /// The builder with the added footer. + public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild) { + var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); + var iconUrl = iconUrlResult.IsSuccess + ? iconUrlResult.Entity.AbsoluteUri + : default(Optional); + + return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl)); + } + + /// + /// Adds a title representing that the action happened in the . + /// + /// The builder to add the title to. + /// The guild whose name and icon to use. + /// The builder with the added title. + public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild) { + var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); + var iconUrl = iconUrlResult.IsSuccess + ? iconUrlResult.Entity.AbsoluteUri + : null; + + builder.Author = new EmbedAuthorBuilder(guild.Name, iconUrl: iconUrl); + return builder; + } + + /// + /// Adds a scheduled event's cover image. + /// + /// The builder to add the image to. + /// The ID of the scheduled event whose image to use. + /// The Optional containing the image hash. + /// The builder with the added cover image. + public static EmbedBuilder WithEventCover( + this EmbedBuilder builder, Snowflake eventId, Optional imageHashOptional) { + if (!imageHashOptional.IsDefined(out var imageHash)) return builder; + + var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024); + return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder; + } + + /// + /// Sanitizes a string for use in by inserting zero-width spaces in between + /// symbols used to format the string with block code. + /// + /// The string to sanitize. + /// The sanitized string that can be safely used in . + private static string SanitizeForBlockCode(this string s) { + return s.Replace("```", "​`​`​`​"); + } + + /// + /// Sanitizes a string (see ) and formats the string with block code. + /// + /// The string to sanitize and format. + /// The sanitized string formatted with . + public static string InBlockCode(this string s) { + s = s.SanitizeForBlockCode(); + return $"```{s.SanitizeForBlockCode()}{(s.EndsWith("`") || string.IsNullOrWhiteSpace(s) ? " " : "")}```"; + } + + public static string Localized(this string key) { + return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key; + } + + /// + /// Encodes a string to allow its transmission in request headers. + /// + /// Used when encountering "Request headers must contain only ASCII characters". + /// The string to encode. + /// An encoded string with spaces kept intact. + public static string EncodeHeader(this string s) { + return WebUtility.UrlEncode(s).Replace('+', ' '); + } + + public static string AsMarkdown(this DiffPaneModel model) { + var builder = new StringBuilder(); + foreach (var line in model.Lines) { + if (line.Type is ChangeType.Deleted) + builder.Append("-- "); + if (line.Type is ChangeType.Inserted) + builder.Append("++ "); + if (line.Type is not ChangeType.Imaginary) + builder.AppendLine(line.Text); + } + + return Markdown.BlockCode(builder.ToString().SanitizeForBlockCode(), "diff"); + } + + public static string GetTag(this IUser user) { + return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}"; + } + + public static Snowflake ToDiscordSnowflake(this ulong id) { + return DiscordSnowflake.New(id); + } + + public static TResult? MaxOrDefault( + this IEnumerable source, Func selector) { + var list = source.ToList(); + return list.Any() ? list.Max(selector) : default; + } + + public static bool TryGetContextIDs( + this ICommandContext context, [NotNullWhen(true)] out Snowflake? guildId, + [NotNullWhen(true)] out Snowflake? channelId, [NotNullWhen(true)] out Snowflake? userId) { + guildId = null; + channelId = null; + userId = null; + return context.TryGetGuildID(out guildId) + && context.TryGetChannelID(out channelId) + && context.TryGetUserID(out userId); + } +} diff --git a/InteractionResponders.cs b/InteractionResponders.cs new file mode 100644 index 0000000..231df31 --- /dev/null +++ b/InteractionResponders.cs @@ -0,0 +1,36 @@ +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.Commands.Feedback.Messages; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Interactivity; +using Remora.Results; + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global + +namespace Boyfriend; + +/// +/// Handles responding to various interactions. +/// +public class InteractionResponders : InteractionGroup { + private readonly FeedbackService _feedbackService; + + public InteractionResponders(FeedbackService feedbackService) { + _feedbackService = feedbackService; + } + + /// + /// A button that will output an ephemeral embed containing the information about a scheduled event. + /// + /// The ID of the guild and scheduled event, encoded as "guildId:eventId". + /// An ephemeral feedback sending result which may or may not have succeeded. + [Button("scheduled-event-details")] + public async Task OnStatefulButtonClicked(string? state = null) { + if (state is null) return Result.FromError(new ArgumentNullError(nameof(state))); + + var idArray = state.Split(':'); + return (Result)await _feedbackService.SendContextualAsync( + $"https://discord.com/events/{idArray[0]}/{idArray[1]}", + options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral)); + } +} diff --git a/Messages.Designer.cs b/Messages.Designer.cs index 54a7eab..9f39239 100644 --- a/Messages.Designer.cs +++ b/Messages.Designer.cs @@ -9,8 +9,8 @@ namespace Boyfriend { using System; - - + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -18,19 +18,19 @@ namespace Boyfriend { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Messages { - + private static global::System.Resources.ResourceManager resourceMan; - + private static global::System.Globalization.CultureInfo resourceCulture; - + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal Messages() { } - + /// /// Returns the cached ResourceManager instance used by this class. /// @@ -44,7 +44,7 @@ namespace Boyfriend { return resourceMan; } } - + /// /// Overrides the current thread's CurrentUICulture property for all /// resource lookups using this strongly typed resource class. @@ -58,34 +58,88 @@ namespace Boyfriend { resourceCulture = value; } } - + /// - /// Looks up a localized string similar to Bah! . + /// Looks up a localized string similar to About Boyfriend. + /// + internal static string AboutBot { + get { + return ResourceManager.GetString("AboutBot", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to logo and embed designer, Boyfriend's Wiki creator. + /// + internal static string AboutDeveloper_mctaylors { + get { + return ResourceManager.GetString("AboutDeveloper@mctaylors", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to developer. + /// + internal static string AboutDeveloper_neroduckale { + get { + return ResourceManager.GetString("AboutDeveloper@neroduckale", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to main developer. + /// + internal static string AboutDeveloper_Octol1ttle { + get { + return ResourceManager.GetString("AboutDeveloper@Octol1ttle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Developers:. + /// + internal static string AboutTitleDevelopers { + get { + return ResourceManager.GetString("AboutTitleDevelopers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Boyfriend's Wiki Page:. + /// + internal static string AboutTitleWiki { + get { + return ResourceManager.GetString("AboutTitleWiki", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Bah!. /// internal static string Beep1 { get { return ResourceManager.GetString("Beep1", resourceCulture); } } - + /// - /// Looks up a localized string similar to Bop! . + /// Looks up a localized string similar to Bop!. /// internal static string Beep2 { get { return ResourceManager.GetString("Beep2", resourceCulture); } } - + /// - /// Looks up a localized string similar to Beep! . + /// Looks up a localized string similar to Beep!. /// internal static string Beep3 { get { return ResourceManager.GetString("Beep3", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot ban users from this guild!. /// @@ -94,7 +148,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotBanMembers", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot ban this user!. /// @@ -103,7 +157,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotBanTarget", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot kick members from this guild!. /// @@ -112,7 +166,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotKickMembers", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot kick this member!. /// @@ -121,7 +175,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotKickTarget", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot manage this guild!. /// @@ -130,7 +184,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotManageGuild", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot manage messages in this guild!. /// @@ -139,7 +193,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotManageMessages", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot moderate members in this guild!. /// @@ -148,7 +202,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotModerateMembers", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot mute this member!. /// @@ -157,7 +211,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotMuteTarget", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot unmute this member!. /// @@ -166,25 +220,34 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotUnmuteTarget", resourceCulture); } } - + /// - /// Looks up a localized string similar to Deleted message from {0} in channel {1}: {2}. + /// Looks up a localized string similar to Cleared message from {0} in channel {1}: {2}. + /// + internal static string CachedMessageCleared { + get { + return ResourceManager.GetString("CachedMessageCleared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Deleted message by {0}:. /// internal static string CachedMessageDeleted { get { return ResourceManager.GetString("CachedMessageDeleted", resourceCulture); } } - + /// - /// Looks up a localized string similar to Edited message in channel {0}: {1} -> {2}. + /// Looks up a localized string similar to Edited message by {0}:. /// internal static string CachedMessageEdited { get { return ResourceManager.GetString("CachedMessageEdited", resourceCulture); } } - + /// /// Looks up a localized string similar to I cannot use time-outs on other bots! Try to set a mute role in settings. /// @@ -193,7 +256,7 @@ namespace Boyfriend { return ResourceManager.GetString("CannotTimeOutBot", resourceCulture); } } - + /// /// Looks up a localized string similar to Not specified. /// @@ -202,7 +265,7 @@ namespace Boyfriend { return ResourceManager.GetString("ChannelNotSpecified", resourceCulture); } } - + /// /// Looks up a localized string similar to You need to specify an integer from {0} to {1} instead of {2}!. /// @@ -211,7 +274,7 @@ namespace Boyfriend { return ResourceManager.GetString("ClearAmountInvalid", resourceCulture); } } - + /// /// Looks up a localized string similar to You specified more than {0} messages!. /// @@ -220,7 +283,7 @@ namespace Boyfriend { return ResourceManager.GetString("ClearAmountTooLarge", resourceCulture); } } - + /// /// Looks up a localized string similar to You specified less than {0} messages!. /// @@ -229,7 +292,7 @@ namespace Boyfriend { return ResourceManager.GetString("ClearAmountTooSmall", resourceCulture); } } - + /// /// Looks up a localized string similar to Bans a user. /// @@ -238,7 +301,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionBan", resourceCulture); } } - + /// /// Looks up a localized string similar to Deletes a specified amount of messages in this channel. /// @@ -247,7 +310,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionClear", resourceCulture); } } - + /// /// Looks up a localized string similar to Shows this message. /// @@ -256,7 +319,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionHelp", resourceCulture); } } - + /// /// Looks up a localized string similar to Kicks a member. /// @@ -265,7 +328,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionKick", resourceCulture); } } - + /// /// Looks up a localized string similar to Mutes a member. /// @@ -274,7 +337,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionMute", resourceCulture); } } - + /// /// Looks up a localized string similar to Shows (inaccurate) latency. /// @@ -283,7 +346,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionPing", resourceCulture); } } - + /// /// Looks up a localized string similar to Adds a reminder. /// @@ -292,7 +355,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionRemind", resourceCulture); } } - + /// /// Looks up a localized string similar to Allows you to change certain preferences for this guild. /// @@ -301,7 +364,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionSettings", resourceCulture); } } - + /// /// Looks up a localized string similar to Unbans a user. /// @@ -310,7 +373,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionUnban", resourceCulture); } } - + /// /// Looks up a localized string similar to Unmutes a member. /// @@ -319,7 +382,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionUnmute", resourceCulture); } } - + /// /// Looks up a localized string similar to Command help:. /// @@ -328,7 +391,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandHelp", resourceCulture); } } - + /// /// Looks up a localized string similar to I do not have permission to execute this command!. /// @@ -337,7 +400,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandNoPermissionBot", resourceCulture); } } - + /// /// Looks up a localized string similar to You do not have permission to execute this command!. /// @@ -346,7 +409,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandNoPermissionUser", resourceCulture); } } - + /// /// Looks up a localized string similar to Current settings:. /// @@ -355,7 +418,7 @@ namespace Boyfriend { return ResourceManager.GetString("CurrentSettings", resourceCulture); } } - + /// /// Looks up a localized string similar to {0}, welcome to {1}. /// @@ -364,7 +427,79 @@ namespace Boyfriend { return ResourceManager.GetString("DefaultWelcomeMessage", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Expires at: {0}. + /// + internal static string DescriptionActionExpiresAt { + get { + return ResourceManager.GetString("DescriptionActionExpiresAt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reason: {0}. + /// + internal static string DescriptionActionReason { + get { + return ResourceManager.GetString("DescriptionActionReason", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The event will start at {0} until {1} in {2}. + /// + internal static string DescriptionExternalEventCreated { + get { + return ResourceManager.GetString("DescriptionExternalEventCreated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The event is happening at {0} until {1}. + /// + internal static string DescriptionExternalEventStarted { + get { + return ResourceManager.GetString("DescriptionExternalEventStarted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The event will start at {0} in {1}. + /// + internal static string DescriptionLocalEventCreated { + get { + return ResourceManager.GetString("DescriptionLocalEventCreated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The event is happening at {0}. + /// + internal static string DescriptionLocalEventStarted { + get { + return ResourceManager.GetString("DescriptionLocalEventStarted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You asked me to remind you {0}. + /// + internal static string DescriptionReminder { + get { + return ResourceManager.GetString("DescriptionReminder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OK, I'll mention you on {0}. + /// + internal static string DescriptionReminderCreated { + get { + return ResourceManager.GetString("DescriptionReminderCreated", resourceCulture); + } + } + /// /// Looks up a localized string similar to I cannot mute someone for more than 28 days using timeouts! Either specify a duration shorter than 28 days, or set a mute role in settings. /// @@ -373,25 +508,25 @@ namespace Boyfriend { return ResourceManager.GetString("DurationRequiredForTimeOuts", resourceCulture); } } - + /// - /// Looks up a localized string similar to Event {0} is cancelled!{1}. + /// Looks up a localized string similar to Event "{0}" is cancelled!. /// internal static string EventCancelled { get { return ResourceManager.GetString("EventCancelled", resourceCulture); } } - + /// - /// Looks up a localized string similar to Event {0} has completed! Duration:{1}. + /// Looks up a localized string similar to Event "{0}" has completed!. /// internal static string EventCompleted { get { return ResourceManager.GetString("EventCompleted", resourceCulture); } } - + /// /// Looks up a localized string similar to {0} has created event {1}! It will take place in {2} and will start <t:{3}:R>! \n {4}. /// @@ -400,7 +535,34 @@ namespace Boyfriend { return ResourceManager.GetString("EventCreated", resourceCulture); } } - + + /// + /// Looks up a localized string similar to {0} has created a new event:. + /// + internal static string EventCreatedTitle { + get { + return ResourceManager.GetString("EventCreatedTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Event details. + /// + internal static string EventDetailsButton { + get { + return ResourceManager.GetString("EventDetailsButton", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The event has lasted for `{0}`. + /// + internal static string EventDuration { + get { + return ResourceManager.GetString("EventDuration", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0}Event {1} will start <t:{2}:R>!. /// @@ -409,16 +571,16 @@ namespace Boyfriend { return ResourceManager.GetString("EventEarlyNotification", resourceCulture); } } - + /// - /// Looks up a localized string similar to {0}Event {1} is starting at {2}!. + /// Looks up a localized string similar to Event "{0}" started. /// internal static string EventStarted { get { return ResourceManager.GetString("EventStarted", resourceCulture); } } - + /// /// Looks up a localized string similar to ever. /// @@ -427,7 +589,7 @@ namespace Boyfriend { return ResourceManager.GetString("Ever", resourceCulture); } } - + /// /// Looks up a localized string similar to Kicked {0}: {1}. /// @@ -436,7 +598,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackMemberKicked", resourceCulture); } } - + /// /// Looks up a localized string similar to Muted {0} for{1}: {2}. /// @@ -445,7 +607,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackMemberMuted", resourceCulture); } } - + /// /// Looks up a localized string similar to Unmuted {0}: {1}. /// @@ -454,16 +616,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackMemberUnmuted", resourceCulture); } } - - /// - /// Looks up a localized string similar to Deleted {0} messages in {1}. - /// - internal static string FeedbackMessagesCleared { - get { - return ResourceManager.GetString("FeedbackMessagesCleared", resourceCulture); - } - } - + /// /// Looks up a localized string similar to Value of setting `{0}` is now set to {1}. /// @@ -472,16 +625,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackSettingsUpdated", resourceCulture); } } - - /// - /// Looks up a localized string similar to Banned {0} for{1}: {2}. - /// - internal static string FeedbackUserBanned { - get { - return ResourceManager.GetString("FeedbackUserBanned", resourceCulture); - } - } - + /// /// Looks up a localized string similar to Unbanned {0}: {1}. /// @@ -490,7 +634,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackUserUnbanned", resourceCulture); } } - + /// /// Looks up a localized string similar to This channel does not exist!. /// @@ -499,619 +643,16 @@ namespace Boyfriend { return ResourceManager.GetString("InvalidChannel", resourceCulture); } } - + /// - /// Looks up a localized string similar to You did not specify a member of this guild!. + /// Looks up a localized string similar to You need to specify a member of this guild!. /// internal static string InvalidMember { get { return ResourceManager.GetString("InvalidMember", resourceCulture); } } - - /// - /// Looks up a localized string similar to This role does not exist!. - /// - internal static string InvalidRole { - get { - return ResourceManager.GetString("InvalidRole", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Invalid setting value specified!. - /// - internal static string InvalidSettingValue { - get { - return ResourceManager.GetString("InvalidSettingValue", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify a user instead of {0}!. - /// - internal static string InvalidUser { - get { - return ResourceManager.GetString("InvalidUser", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Language not supported! Supported languages:. - /// - internal static string LanguageNotSupported { - get { - return ResourceManager.GetString("LanguageNotSupported", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Member is already muted!. - /// - internal static string MemberAlreadyMuted { - get { - return ResourceManager.GetString("MemberAlreadyMuted", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Member not muted!. - /// - internal static string MemberNotMuted { - get { - return ResourceManager.GetString("MemberNotMuted", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to ms. - /// - internal static string Milliseconds { - get { - return ResourceManager.GetString("Milliseconds", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify a reason to ban this user!. - /// - internal static string MissingBanReason { - get { - return ResourceManager.GetString("MissingBanReason", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify a reason to kick this member!. - /// - internal static string MissingKickReason { - get { - return ResourceManager.GetString("MissingKickReason", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify a guild member!. - /// - internal static string MissingMember { - get { - return ResourceManager.GetString("MissingMember", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify a reason to mute this member!. - /// - internal static string MissingMuteReason { - get { - return ResourceManager.GetString("MissingMuteReason", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify an integer from {0} to {1}!. - /// - internal static string MissingNumber { - get { - return ResourceManager.GetString("MissingNumber", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify reminder text!. - /// - internal static string MissingReminderText { - get { - return ResourceManager.GetString("MissingReminderText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify a reason to unban this user!. - /// - internal static string MissingUnbanReason { - get { - return ResourceManager.GetString("MissingUnbanReason", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify a reason for unmute this member!. - /// - internal static string MissingUnmuteReason { - get { - return ResourceManager.GetString("MissingUnmuteReason", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You need to specify a user!. - /// - internal static string MissingUser { - get { - return ResourceManager.GetString("MissingUser", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No. - /// - internal static string No { - get { - return ResourceManager.GetString("No", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Punishment expired. - /// - internal static string PunishmentExpired { - get { - return ResourceManager.GetString("PunishmentExpired", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0}I'm ready!. - /// - internal static string Ready { - get { - return ResourceManager.GetString("Ready", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Not specified. - /// - internal static string RoleNotSpecified { - get { - return ResourceManager.GetString("RoleNotSpecified", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to That setting doesn't exist!. - /// - internal static string SettingDoesntExist { - get { - return ResourceManager.GetString("SettingDoesntExist", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Not specified. - /// - internal static string SettingNotDefined { - get { - return ResourceManager.GetString("SettingNotDefined", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Automatically start scheduled events. - /// - internal static string SettingsAutoStartEvents { - get { - return ResourceManager.GetString("SettingsAutoStartEvents", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Early event start notification offset. - /// - internal static string SettingsEventEarlyNotificationOffset { - get { - return ResourceManager.GetString("SettingsEventEarlyNotificationOffset", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Channel for event notifications. - /// - internal static string SettingsEventNotificationChannel { - get { - return ResourceManager.GetString("SettingsEventNotificationChannel", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Role for event creation notifications. - /// - internal static string SettingsEventNotificationRole { - get { - return ResourceManager.GetString("SettingsEventNotificationRole", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Event start notifications receivers. - /// - internal static string SettingsEventStartedReceivers { - get { - return ResourceManager.GetString("SettingsEventStartedReceivers", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to :(. - /// - internal static string SettingsFrowningFace { - get { - return ResourceManager.GetString("SettingsFrowningFace", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Language. - /// - internal static string SettingsLang { - get { - return ResourceManager.GetString("SettingsLang", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Mute role. - /// - internal static string SettingsMuteRole { - get { - return ResourceManager.GetString("SettingsMuteRole", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Nothing changed! `{0}` is already set to {1}. - /// - internal static string SettingsNothingChanged { - get { - return ResourceManager.GetString("SettingsNothingChanged", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Prefix. - /// - internal static string SettingsPrefix { - get { - return ResourceManager.GetString("SettingsPrefix", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Channel for private notifications. - /// - internal static string SettingsPrivateFeedbackChannel { - get { - return ResourceManager.GetString("SettingsPrivateFeedbackChannel", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Channel for public notifications. - /// - internal static string SettingsPublicFeedbackChannel { - get { - return ResourceManager.GetString("SettingsPublicFeedbackChannel", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Receive startup messages. - /// - internal static string SettingsReceiveStartupMessages { - get { - return ResourceManager.GetString("SettingsReceiveStartupMessages", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Remove roles on mute. - /// - internal static string SettingsRemoveRolesOnMute { - get { - return ResourceManager.GetString("SettingsRemoveRolesOnMute", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Return roles on rejoin. - /// - internal static string SettingsReturnRolesOnRejoin { - get { - return ResourceManager.GetString("SettingsReturnRolesOnRejoin", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Send welcome messages. - /// - internal static string SettingsSendWelcomeMessages { - get { - return ResourceManager.GetString("SettingsSendWelcomeMessages", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Starter role. - /// - internal static string SettingsStarterRole { - get { - return ResourceManager.GetString("SettingsStarterRole", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Welcome message. - /// - internal static string SettingsWelcomeMessage { - get { - return ResourceManager.GetString("SettingsWelcomeMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot ban me!. - /// - internal static string UserCannotBanBot { - get { - return ResourceManager.GetString("UserCannotBanBot", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot ban users from this guild!. - /// - internal static string UserCannotBanMembers { - get { - return ResourceManager.GetString("UserCannotBanMembers", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot ban the owner of this guild!. - /// - internal static string UserCannotBanOwner { - get { - return ResourceManager.GetString("UserCannotBanOwner", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot ban this user!. - /// - internal static string UserCannotBanTarget { - get { - return ResourceManager.GetString("UserCannotBanTarget", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot ban yourself!. - /// - internal static string UserCannotBanThemselves { - get { - return ResourceManager.GetString("UserCannotBanThemselves", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot kick me!. - /// - internal static string UserCannotKickBot { - get { - return ResourceManager.GetString("UserCannotKickBot", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot kick members from this guild!. - /// - internal static string UserCannotKickMembers { - get { - return ResourceManager.GetString("UserCannotKickMembers", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot kick the owner of this guild!. - /// - internal static string UserCannotKickOwner { - get { - return ResourceManager.GetString("UserCannotKickOwner", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot kick this member!. - /// - internal static string UserCannotKickTarget { - get { - return ResourceManager.GetString("UserCannotKickTarget", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot kick yourself!. - /// - internal static string UserCannotKickThemselves { - get { - return ResourceManager.GetString("UserCannotKickThemselves", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot manage this guild!. - /// - internal static string UserCannotManageGuild { - get { - return ResourceManager.GetString("UserCannotManageGuild", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot manage messages in this guild!. - /// - internal static string UserCannotManageMessages { - get { - return ResourceManager.GetString("UserCannotManageMessages", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot moderate members in this guild!. - /// - internal static string UserCannotModerateMembers { - get { - return ResourceManager.GetString("UserCannotModerateMembers", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot mute me!. - /// - internal static string UserCannotMuteBot { - get { - return ResourceManager.GetString("UserCannotMuteBot", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot mute the owner of this guild!. - /// - internal static string UserCannotMuteOwner { - get { - return ResourceManager.GetString("UserCannotMuteOwner", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot mute this member!. - /// - internal static string UserCannotMuteTarget { - get { - return ResourceManager.GetString("UserCannotMuteTarget", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot mute yourself!. - /// - internal static string UserCannotMuteThemselves { - get { - return ResourceManager.GetString("UserCannotMuteThemselves", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to .... - /// - internal static string UserCannotUnmuteBot { - get { - return ResourceManager.GetString("UserCannotUnmuteBot", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You don't need to unmute the owner of this guild!. - /// - internal static string UserCannotUnmuteOwner { - get { - return ResourceManager.GetString("UserCannotUnmuteOwner", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You cannot unmute this user!. - /// - internal static string UserCannotUnmuteTarget { - get { - return ResourceManager.GetString("UserCannotUnmuteTarget", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You are muted!. - /// - internal static string UserCannotUnmuteThemselves { - get { - return ResourceManager.GetString("UserCannotUnmuteThemselves", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to This user is not banned!. - /// - internal static string UserNotBanned { - get { - return ResourceManager.GetString("UserNotBanned", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago. - /// - internal static string UserNotFound { - get { - return ResourceManager.GetString("UserNotFound", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Yes. - /// - internal static string Yes { - get { - return ResourceManager.GetString("Yes", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You were banned by {0} in guild `{1}` for {2}. - /// - internal static string YouWereBanned { - get { - return ResourceManager.GetString("YouWereBanned", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You were kicked by {0} in guild `{1}` for {2}. - /// - internal static string YouWereKicked { - get { - return ResourceManager.GetString("YouWereKicked", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to OK, I'll mention you on <t:{0}:f>. - /// - internal static string FeedbackReminderAdded { - get { - return ResourceManager.GetString("FeedbackReminderAdded", resourceCulture); - } - } - + /// /// Looks up a localized string similar to You need to specify when I should send you the reminder!. /// @@ -1120,13 +661,760 @@ namespace Boyfriend { return ResourceManager.GetString("InvalidRemindIn", resourceCulture); } } - + /// - /// Looks up a localized string similar to Deleted message using cleanup from {0} in channel {1}: {2}. + /// Looks up a localized string similar to This role does not exist!. /// - internal static string CachedMessageCleared { + internal static string InvalidRole { get { - return ResourceManager.GetString("CachedMessageCleared", resourceCulture); + return ResourceManager.GetString("InvalidRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid setting value specified!. + /// + internal static string InvalidSettingValue { + get { + return ResourceManager.GetString("InvalidSettingValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify a user instead of {0}!. + /// + internal static string InvalidUser { + get { + return ResourceManager.GetString("InvalidUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Issued by. + /// + internal static string IssuedBy { + get { + return ResourceManager.GetString("IssuedBy", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Language not supported!. + /// + internal static string LanguageNotSupported { + get { + return ResourceManager.GetString("LanguageNotSupported", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Member is already muted!. + /// + internal static string MemberAlreadyMuted { + get { + return ResourceManager.GetString("MemberAlreadyMuted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Member not muted!. + /// + internal static string MemberNotMuted { + get { + return ResourceManager.GetString("MemberNotMuted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to From {0}:. + /// + internal static string MessageFrom { + get { + return ResourceManager.GetString("MessageFrom", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cleared {0} messages. + /// + internal static string MessagesCleared { + get { + return ResourceManager.GetString("MessagesCleared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ms. + /// + internal static string Milliseconds { + get { + return ResourceManager.GetString("Milliseconds", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify a reason to ban this user!. + /// + internal static string MissingBanReason { + get { + return ResourceManager.GetString("MissingBanReason", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify a reason to kick this member!. + /// + internal static string MissingKickReason { + get { + return ResourceManager.GetString("MissingKickReason", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify a guild member!. + /// + internal static string MissingMember { + get { + return ResourceManager.GetString("MissingMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify a reason to mute this member!. + /// + internal static string MissingMuteReason { + get { + return ResourceManager.GetString("MissingMuteReason", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify an integer from {0} to {1}!. + /// + internal static string MissingNumber { + get { + return ResourceManager.GetString("MissingNumber", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify reminder text!. + /// + internal static string MissingReminderText { + get { + return ResourceManager.GetString("MissingReminderText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify a reason to unban this user!. + /// + internal static string MissingUnbanReason { + get { + return ResourceManager.GetString("MissingUnbanReason", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify a reason for unmute this member!. + /// + internal static string MissingUnmuteReason { + get { + return ResourceManager.GetString("MissingUnmuteReason", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need to specify a user!. + /// + internal static string MissingUser { + get { + return ResourceManager.GetString("MissingUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No. + /// + internal static string No { + get { + return ResourceManager.GetString("No", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Punishment expired. + /// + internal static string PunishmentExpired { + get { + return ResourceManager.GetString("PunishmentExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to I'm ready!. + /// + internal static string Ready { + get { + return ResourceManager.GetString("Ready", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reminder for {0}. + /// + internal static string Reminder { + get { + return ResourceManager.GetString("Reminder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reminder for {0} created. + /// + internal static string ReminderCreated { + get { + return ResourceManager.GetString("ReminderCreated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not specified. + /// + internal static string RoleNotSpecified { + get { + return ResourceManager.GetString("RoleNotSpecified", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to That setting doesn't exist!. + /// + internal static string SettingDoesntExist { + get { + return ResourceManager.GetString("SettingDoesntExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to is now. + /// + internal static string SettingIsNow { + get { + return ResourceManager.GetString("SettingIsNow", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Setting not changed. + /// + internal static string SettingNotChanged { + get { + return ResourceManager.GetString("SettingNotChanged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not specified. + /// + internal static string SettingNotDefined { + get { + return ResourceManager.GetString("SettingNotDefined", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Automatically start scheduled events. + /// + internal static string SettingsAutoStartEvents { + get { + return ResourceManager.GetString("SettingsAutoStartEvents", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Default role. + /// + internal static string SettingsDefaultRole { + get { + return ResourceManager.GetString("SettingsDefaultRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Early event start notification offset. + /// + internal static string SettingsEventEarlyNotificationOffset { + get { + return ResourceManager.GetString("SettingsEventEarlyNotificationOffset", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Channel for event notifications. + /// + internal static string SettingsEventNotificationChannel { + get { + return ResourceManager.GetString("SettingsEventNotificationChannel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Role for event creation notifications. + /// + internal static string SettingsEventNotificationRole { + get { + return ResourceManager.GetString("SettingsEventNotificationRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Event start notifications receivers. + /// + internal static string SettingsEventStartedReceivers { + get { + return ResourceManager.GetString("SettingsEventStartedReceivers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to :(. + /// + internal static string SettingsFrowningFace { + get { + return ResourceManager.GetString("SettingsFrowningFace", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Language. + /// + internal static string SettingsLang { + get { + return ResourceManager.GetString("SettingsLang", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Boyfriend's Settings. + /// + internal static string SettingsListTitle { + get { + return ResourceManager.GetString("SettingsListTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Mute role. + /// + internal static string SettingsMuteRole { + get { + return ResourceManager.GetString("SettingsMuteRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nothing changed! `{0}` is already set to {1}. + /// + internal static string SettingsNothingChanged { + get { + return ResourceManager.GetString("SettingsNothingChanged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Prefix. + /// + internal static string SettingsPrefix { + get { + return ResourceManager.GetString("SettingsPrefix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Channel for private notifications. + /// + internal static string SettingsPrivateFeedbackChannel { + get { + return ResourceManager.GetString("SettingsPrivateFeedbackChannel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Channel for public notifications. + /// + internal static string SettingsPublicFeedbackChannel { + get { + return ResourceManager.GetString("SettingsPublicFeedbackChannel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Receive startup messages. + /// + internal static string SettingsReceiveStartupMessages { + get { + return ResourceManager.GetString("SettingsReceiveStartupMessages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remove roles on mute. + /// + internal static string SettingsRemoveRolesOnMute { + get { + return ResourceManager.GetString("SettingsRemoveRolesOnMute", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Return roles on rejoin. + /// + internal static string SettingsReturnRolesOnRejoin { + get { + return ResourceManager.GetString("SettingsReturnRolesOnRejoin", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Send welcome messages. + /// + internal static string SettingsSendWelcomeMessages { + get { + return ResourceManager.GetString("SettingsSendWelcomeMessages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Setting successfuly changed. + /// + internal static string SettingSuccessfulyChanged { + get { + return ResourceManager.GetString("SettingSuccessfulyChanged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Welcome message. + /// + internal static string SettingsWelcomeMessage { + get { + return ResourceManager.GetString("SettingsWelcomeMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This user is already banned!. + /// + internal static string UserAlreadyBanned { + get { + return ResourceManager.GetString("UserAlreadyBanned", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This user is already muted!. + /// + internal static string UserAlreadyMuted { + get { + return ResourceManager.GetString("UserAlreadyMuted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} was banned. + /// + internal static string UserBanned { + get { + return ResourceManager.GetString("UserBanned", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot ban me!. + /// + internal static string UserCannotBanBot { + get { + return ResourceManager.GetString("UserCannotBanBot", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot ban users from this guild!. + /// + internal static string UserCannotBanMembers { + get { + return ResourceManager.GetString("UserCannotBanMembers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot ban the owner of this guild!. + /// + internal static string UserCannotBanOwner { + get { + return ResourceManager.GetString("UserCannotBanOwner", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot ban this user!. + /// + internal static string UserCannotBanTarget { + get { + return ResourceManager.GetString("UserCannotBanTarget", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot ban yourself!. + /// + internal static string UserCannotBanThemselves { + get { + return ResourceManager.GetString("UserCannotBanThemselves", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot kick me!. + /// + internal static string UserCannotKickBot { + get { + return ResourceManager.GetString("UserCannotKickBot", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot kick members from this guild!. + /// + internal static string UserCannotKickMembers { + get { + return ResourceManager.GetString("UserCannotKickMembers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot kick the owner of this guild!. + /// + internal static string UserCannotKickOwner { + get { + return ResourceManager.GetString("UserCannotKickOwner", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot kick this member!. + /// + internal static string UserCannotKickTarget { + get { + return ResourceManager.GetString("UserCannotKickTarget", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot kick yourself!. + /// + internal static string UserCannotKickThemselves { + get { + return ResourceManager.GetString("UserCannotKickThemselves", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot manage this guild!. + /// + internal static string UserCannotManageGuild { + get { + return ResourceManager.GetString("UserCannotManageGuild", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot manage messages in this guild!. + /// + internal static string UserCannotManageMessages { + get { + return ResourceManager.GetString("UserCannotManageMessages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot moderate members in this guild!. + /// + internal static string UserCannotModerateMembers { + get { + return ResourceManager.GetString("UserCannotModerateMembers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot mute me!. + /// + internal static string UserCannotMuteBot { + get { + return ResourceManager.GetString("UserCannotMuteBot", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot mute the owner of this guild!. + /// + internal static string UserCannotMuteOwner { + get { + return ResourceManager.GetString("UserCannotMuteOwner", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot mute this member!. + /// + internal static string UserCannotMuteTarget { + get { + return ResourceManager.GetString("UserCannotMuteTarget", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot mute yourself!. + /// + internal static string UserCannotMuteThemselves { + get { + return ResourceManager.GetString("UserCannotMuteThemselves", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to .... + /// + internal static string UserCannotUnmuteBot { + get { + return ResourceManager.GetString("UserCannotUnmuteBot", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You don't need to unmute the owner of this guild!. + /// + internal static string UserCannotUnmuteOwner { + get { + return ResourceManager.GetString("UserCannotUnmuteOwner", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot unmute this user!. + /// + internal static string UserCannotUnmuteTarget { + get { + return ResourceManager.GetString("UserCannotUnmuteTarget", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are muted!. + /// + internal static string UserCannotUnmuteThemselves { + get { + return ResourceManager.GetString("UserCannotUnmuteThemselves", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} was kicked. + /// + internal static string UserKicked { + get { + return ResourceManager.GetString("UserKicked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} was muted. + /// + internal static string UserMuted { + get { + return ResourceManager.GetString("UserMuted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This user is not banned!. + /// + internal static string UserNotBanned { + get { + return ResourceManager.GetString("UserNotBanned", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago. + /// + internal static string UserNotFound { + get { + return ResourceManager.GetString("UserNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to I could not find this user!. + /// + internal static string UserNotFoundShort { + get { + return ResourceManager.GetString("UserNotFoundShort", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This member is not muted!. + /// + internal static string UserNotMuted { + get { + return ResourceManager.GetString("UserNotMuted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} was unbanned. + /// + internal static string UserUnbanned { + get { + return ResourceManager.GetString("UserUnbanned", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} was unmuted. + /// + internal static string UserUnmuted { + get { + return ResourceManager.GetString("UserUnmuted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes. + /// + internal static string Yes { + get { + return ResourceManager.GetString("Yes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You were banned. + /// + internal static string YouWereBanned { + get { + return ResourceManager.GetString("YouWereBanned", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You were kicked. + /// + internal static string YouWereKicked { + get { + return ResourceManager.GetString("YouWereKicked", resourceCulture); } } } diff --git a/Messages.resx b/Messages.resx index 7e771ae..0bb7ab5 100644 --- a/Messages.resx +++ b/Messages.resx @@ -1,145 +1,133 @@  - - - - - - + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - {0}I'm ready! + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + I'm ready! - - Deleted message from {0} in channel {1}: {2} + + Deleted message by {0}: - + Cleared message from {0} in channel {1}: {2} - - Edited message in channel {0}: {1} -> {2} + + Edited message by {0}: - + {0}, welcome to {1} - - Bah! + + Bah! - - Bop! + + Bop! - Beep! + Beep! I do not have permission to execute this command! @@ -148,8 +136,8 @@ You do not have permission to execute this command! - You were banned by {0} in guild `{1}` for {2} - + You were banned + Punishment expired @@ -163,8 +151,8 @@ Command help: - You were kicked by {0} in guild `{1}` for {2} - + You were kicked + ms @@ -177,148 +165,148 @@ Not specified - + Current settings: - + Language - + Prefix - + Remove roles on mute - + Send welcome messages Mute role - Language not supported! Supported languages: - - + Language not supported! + + Yes - + No - + This user is not banned! - + Member not muted! Welcome message - + You need to specify an integer from {0} to {1} instead of {2}! - - Banned {0} for{1}: {2} + + {0} was banned - + That setting doesn't exist! - + Receive startup messages - + Invalid setting value specified! - + This role does not exist! - + This channel does not exist! I cannot mute someone for more than 28 days using timeouts! Either specify a duration shorter than 28 days, or set a mute role in settings - + I cannot use time-outs on other bots! Try to set a mute role in settings - + {0} has created event {1}! It will take place in {2} and will start <t:{3}:R>! \n {4} - + Role for event creation notifications - + Channel for event notifications - + Event start notifications receivers - - {0}Event {1} is starting at {2}! + + Event "{0}" started - + :( - - Event {0} is cancelled!{1} + + Event "{0}" is cancelled! - - Event {0} has completed! Duration:{1} + + Event "{0}" has completed! - + ever - - Deleted {0} messages in {1} + + Cleared {0} messages - + Kicked {0}: {1} - + Muted {0} for{1}: {2} - + Unbanned {0}: {1} - + Unmuted {0}: {1} - + Nothing changed! `{0}` is already set to {1} - + Not specified - + Value of setting `{0}` is now set to {1} - + Bans a user - + Deletes a specified amount of messages in this channel - + Shows this message - + Kicks a member - + Mutes a member - + Shows (inaccurate) latency - + Allows you to change certain preferences for this guild - + Unbans a user - + Unmutes a member - + You need to specify an integer from {0} to {1}! @@ -331,8 +319,8 @@ You need to specify a guild member! - You need to specify a member of this guild! - + You need to specify a member of this guild! + You cannot ban users from this guild! @@ -345,94 +333,94 @@ You cannot moderate members in this guild! - + You cannot manage this guild! - + I cannot ban users from this guild! - + I cannot manage messages in this guild! - + I cannot kick members from this guild! - + I cannot moderate members in this guild! - + I cannot manage this guild! - + You need to specify a reason to ban this user! - + You need to specify a reason to kick this member! - + You need to specify a reason to mute this member! - + You need to specify a reason to unban this user! - + You need to specify a reason for unmute this member! You cannot ban the owner of this guild! - + You cannot ban yourself! - + You cannot ban me! - + I cannot ban this user! - + You cannot ban this user! - + You cannot kick the owner of this guild! - + You cannot kick yourself! - + You cannot kick me! - + I cannot kick this member! - + You cannot kick this member! - + You cannot mute the owner of this guild! - + You cannot mute yourself! - + You cannot mute me! - + I cannot mute this member! - + You cannot mute this member! - + You don't need to unmute the owner of this guild! - + You are muted! - + ... - + I cannot unmute this member! @@ -445,33 +433,129 @@ Early event start notification offset - I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago - - - Starter role - + I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago + + + Default role + - Adds a reminder - + Adds a reminder + - Channel for public notifications - + Channel for public notifications + - Channel for private notifications - + Channel for private notifications + - Return roles on rejoin - + Return roles on rejoin + - Automatically start scheduled events - + Automatically start scheduled events + - You need to specify reminder text! - - - OK, I'll mention you on <t:{0}:f> + You need to specify reminder text! + + + OK, I'll mention you on {0} - You need to specify when I should send you the reminder! + You need to specify when I should send you the reminder! + + + Issued by + + + {0} has created a new event: + + + The event will start at {0} in {1} + + + The event will start at {0} until {1} in {2} + + + Event details + + + The event has lasted for `{0}` + + + The event is happening at {0} + + + The event is happening at {0} until {1} + + + This user is already banned! + + + {0} was unbanned + + + {0} was muted + + + {0} was unmuted + + + This member is not muted! + + + I could not find this user! + + + {0} was kicked + + + Reason: {0} + + + Expires at: {0} + + + This user is already muted! + + + From {0}: + + + Developers: + + + Boyfriend's Wiki Page: + + + About Boyfriend + + + logo and embed designer, Boyfriend's Wiki creator + + + main developer + + + developer + + + Reminder for {0} created + + + Reminder for {0} + + + You asked me to remind you {0} + + + Boyfriend's Settings + + + Setting successfuly changed + + + Setting not changed + + + is now diff --git a/Messages.ru.resx b/Messages.ru.resx index cd5fecd..1351ea1 100644 --- a/Messages.ru.resx +++ b/Messages.ru.resx @@ -1,145 +1,133 @@  - - - - - - + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - {0}Я запустился! + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Я запустился! - - Удалено сообщение от {0} в канале {1}: {2} + + Сообщение {0} удалено: - + Очищено сообщение от {0} в канале {1}: {2} - - Отредактировано сообщение в канале {0}: {1} -> {2} + + Сообщение {0} отредактировано: - + {0}, добро пожаловать на сервер {1} - - Бап! + + Бап! - - Боп! + + Боп! - Бип! + Бип! У меня недостаточно прав для выполнения этой команды! @@ -147,9 +135,6 @@ У тебя недостаточно прав для выполнения этой команды! - - Тебя забанил {0} на сервере `{1}` за {2} - Время наказания истекло @@ -163,8 +148,8 @@ Справка по командам: - Тебя кикнул {0} на сервере `{1}` за {2} - + Вы были выгнаны + мс @@ -177,148 +162,148 @@ Не указана - + Текущие настройки: - + Язык - + Префикс - + Удалять роли при муте - + Отправлять приветствия - + Роль мута - Язык не поддерживается! Поддерживаемые языки: - - + Язык не поддерживается! + + Да - + Нет - + Этот пользователь не забанен! - + Участник не заглушен! Приветствие - + Надо указать целое число от {0} до {1} вместо {2}! - - Забанен {0} на{1}: {2} + + {0} был(-а) забанен(-а) - + Такая настройка не существует! - + Получать сообщения о запуске - + Указано недействительное значение для настройки! - + Эта роль не существует! - + Этот канал не существует! Я не могу заглушить кого-то на более чем 28 дней, используя тайм-ауты! Или укажи продолжительность менее 28 дней, или установи роль мута в настройках - + Я не могу использовать тайм-ауты на других ботах! Попробуй указать роль мута в настройках {0} создал событие {1}! Оно пройдёт в {2} и начнётся <t:{3}:R>!{4} - + Роль для уведомлений о создании событий - + Канал для уведомлений о событиях - + Получатели уведомлений о начале событий - - {0}Событие {1} начинается в {2}! + + Событие "{0}" началось - + :( - - Событие {0} отменено!{1} + + Событие "{0}" отменено! - - Событие {0} завершено! Продолжительность:{1} + + Событие "{0}" завершено! - + всегда - - Удалено {0} сообщений в {1} + + Очищено {0} сообщений - + Выгнан {0}: {1} - + Заглушен {0} на{1}: {2} - + Возвращён из бана {0}: {1} - + Разглушен {0}: {1} - + Ничего не изменилось! Значение настройки `{0}` уже {1} - + Не указано - + Значение настройки `{0}` теперь установлено на {1} - + Банит пользователя - + Удаляет указанное количество сообщений в этом канале - + Показывает эту справку - + Выгоняет участника - + Глушит участника - + Показывает (неточную) задержку - + Позволяет менять некоторые настройки под этот сервер - + Возвращает пользователя из бана - + Разглушает участника - + Надо указать целое число от {0} до {1}! @@ -331,8 +316,8 @@ Надо указать участника сервера! - Надо указать участника этого сервера! - + Надо указать участника этого сервера! + Ты не можешь банить пользователей на этом сервере! @@ -345,94 +330,94 @@ Ты не можешь модерировать участников этого сервера! - + Ты не можешь настраивать этот сервер! - + Я не могу банить пользователей на этом сервере! - + Я не могу управлять сообщениями этого сервера! - + Я не могу выгонять участников с этого сервера! - + Я не могу модерировать участников этого сервера! - + Я не могу настраивать этот сервер! - + Надо указать причину для бана этого участника! - + Надо указать причину для кика этого участника! - + Надо указать причину для мута этого участника! Надо указать причину для разбана этого пользователя! - + Надо указать причину для размута этого участника! - + Ты не можешь меня забанить! - + Ты не можешь забанить владельца этого сервера! - + Ты не можешь забанить этого участника! - + Ты не можешь себя забанить! - + Я не могу забанить этого пользователя! - + Ты не можешь выгнать владельца этого сервера! - + Ты не можешь себя выгнать! - + Ты не можешь меня выгнать! - + Я не могу выгнать этого участника - + Ты не можешь выгнать этого участника! - + Ты не можешь заглушить владельца этого сервера! - + Ты не можешь себя заглушить! - + Ты не можешь заглушить меня! - + Я не могу заглушить этого пользователя! - + Ты не можешь заглушить этого участника! - + Тебе не надо возвращать из мута владельца этого сервера! - + Ты заглушен! - + ... - + Ты не можешь вернуть из мута этого пользователя! @@ -445,33 +430,132 @@ Офсет отправки преждевременного уведомления о начале события - Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад - - - Начальная роль - + Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад + + + Общая роль + - Добавляет напоминание - + Добавляет напоминание + - Канал для публичных уведомлений - + Канал для публичных уведомлений + - Канал для приватных уведомлений - + Канал для приватных уведомлений + - Возвращать роли при перезаходе + Возвращать роли при перезаходе + + + Автоматически начинать события + + + Тебе нужно указать текст напоминания! + + + Хорошо, я упомяну тебя {0} - - Автоматически начинать события + + Нужно указать время, через которое придёт напоминание! + + + Ответственный + + + {0} создаёт новое событие: + + + Событие пройдёт {0} в канале {1} + + + Событие пройдёт с {0} до {1} в {2} + + + Подробнее о событии + + + Событие длилось `{0}` + + + Событие происходит в {0} + + + Событие происходит в {0} до {1} + + + Этот пользователь уже забанен! + + + {0} был(-а) разбанен(-а) + + + {0} был(-а) заглушен(-а) + + + Этот участник не заглушен! + + + {0} был(-а) разглушен(-а) + + + Я не смог найти этого пользователя! + + + {0} был(-а) выгнан(-а) + + + Причина: {0} + + + Закончится: {0} + + + Этот пользователь уже в муте! + + + Вы были забанены + + + От {0}: + + + Разработчики: + + + Страница Boyfriend's Wiki: + + + О Boyfriend + + + разрабочик + + + основной разработчик + + + дизайнер лого и эмбедов, создатель Boyfriend's Wiki + + + Напоминание для {0} создано - - Тебе нужно указать текст напоминания! + + Напоминание для {0} - - Хорошо, я упомяну тебя <t:{0}:f> + + Вы просили напомнить вам {0} - - Нужно указать время, через которое придёт напоминание! + + Настройки Boyfriend + + + Настройка успешно изменена + + + Настройка не редактирована + + + теперь имеет значение diff --git a/Messages.tt-ru.resx b/Messages.tt-ru.resx index 88364b2..e4a75be 100644 --- a/Messages.tt-ru.resx +++ b/Messages.tt-ru.resx @@ -1,145 +1,133 @@ - - - - - - + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - {0}я родился! + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + я родился! - - вырезано сообщение от {0} в канале {1}: {2} + + сообщение {0} вырезано: - + вырезано сообщение (используя `!clear`) от {0} в канале {1}: {2} - - переделано сообщение от {0}: {1} -> {2} + + сообщение {0} переделано: - + {0}, добро пожаловать на сервер {1} - - брах! + + брах! - - брох! + + брох! - брух! + брух! у меня прав нету, сделай что нибудь. @@ -148,8 +136,8 @@ у тебя прав нету, твои проблемы. - здарова, тебя крч забанил {0} на сервере `{1}` за {2} - + вы были забанены + время бана закончиловсь @@ -163,8 +151,8 @@ туториал по приколам: - здарова, тебя крч кикнул {0} на сервере `{1}` за {2} - + вы были кикнуты + мс @@ -175,150 +163,150 @@ *тут ничего нет* - нъет - - + нъет + + настройки: - + язык - + префикс - + удалять звание при муте - + разглашать о том что пришел новый шизоид - звание замученного - - - такого языка нету, ты шо, есть только такие: + звание замученного - + + такого языка нету... + + да - + нъет - + шизик не забанен - + шизоид не замучен! - здравствуйте (типо настройка) - - + здравствуйте (типо настройка) + + выбери число от {0} до {1} вместо {2}! - - забанен {0} на{1}: {2} + + {0} забанен - + такой прикол не существует - получать инфу о старте бота - - + получать инфу о старте бота + + криво настроил прикол, давай по новой - + этого звания нету, ты шо - + этого канала нету, ты шо ты шо, мутить больше чем на 28 дней таймаут не разрешает, вот настроишь роль мута, тогда поговорим - + я не могу замутить ботов, сделай что нибудь - {0} приготовил новый квест {1}! он пройдёт в {2} и начнётся <t:{3}:R>!{4} + {0} приготовил новую движуху {1}! она пройдёт в {2} и начнётся <t:{3}:R>!{4} - - роль для уведомлений о создании квеста + + роль для уведомлений о создании движухи - - канал для уведомлений о квестах + + канал для уведомлений о движухах - - получатели уведомлений о начале квеста + + получатели уведомлений о начале движух - - {0}квест {1} начинается в {2}! + + движуха "{0}" начинается - - оъмъомоъемъъео(((( + + оъмъомоъемъъео(((( - - квест {0} отменен!{1} + + движуха "{0}" отменен! - - квест {0} завершен! все это длилось{1} + + движуха "{0}" завершен! - + всегда - - удалено {0} сообщений в {1} + + вырезано {0} забавных сообщений - + выгнан {0}: {1} - + замучен {0} на{1}: {2} - + раззабанен {0}: {1} - + раззамучен {0}: {1} - + ты все сломал! значение прикола `{0}` и так {1} - нъет - - + нъет + + прикол для `{0}` теперь установлен на {1} - + возводит великий банхаммер над шизоидом - + удаляет сообщения. сколько хош, столько и удалит - + показывает то, что ты сейчас видишь прямо сейчас - + выпинывает шизоида - + мутит шизоида - + показывает пинг (сверхмегаточный (нет)) - + настройки бота под этот сервер - + отводит великий банхаммер от шизоида - + раззамучивает шизоида - + укажи целое число от {0} до {1} @@ -331,8 +319,8 @@ укажи самого шизика - укажи шизоида сервера! - + укажи шизоида сервера! + бан @@ -345,133 +333,229 @@ тебе нельзя управлять шизоидами - + тебе нельзя редактировать дурку - + я не могу ваще никого банить чел. - + я не могу исправлять орфографический кринж участников, сделай что нибудь. - + я не могу ваще никого кикать чел. - + я не могу контроллировать за всеми ними, сделай что нибудь. - + я не могу этому серверу хоть че либо нибудь изменить, сделай что нибудь. - + укажи зачем банить шизика - + укажи зачем кикать шизика - + укажи зачем мутить шизика укажи зачем раззабанивать шизика - + укажи зачам размучивать шизика - + ээбля френдли фаер огонь по своим - + бан админу нельзя - + бан этому шизику нельзя - + самобан нельзя - + я не могу его забанить... - + кик админу нельзя - + самокик нельзя - + ээбля френдли фаер огонь по своим - + я не могу его кикнуть... - + кик этому шизику нельзя - + мут админу нельзя - + самомут нельзя - + ээбля френдли фаер огонь по своим - + я не могу его замутить... - + мут этому шизику нельзя - сильно - - + сильно + + ты замучен. - + ... - + тебе нельзя раззамучивать я не могу его раззамутить... - {0}квест {1} начнется <t:{2}:R>! + {0}движуха {1} начнется <t:{2}:R>! - заранее пнуть в минутах до начала квеста + заранее пнуть в минутах до начала движухи - у нас такого шизоида нету, проверь, валиден ли ID уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад) - - - базовое звание - + у нас такого шизоида нету, проверь, валиден ли ID уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад) + + + дефолтное звание + - крафтит напоминалку - + крафтит напоминалку + - канал для секретных уведомлений - + канал для секретных уведомлений + - канал для не секретных уведомлений - + канал для не секретных уведомлений + - вернуть звания при переподключении в дурку + вернуть звания при переподключении в дурку + + + автоматом стартить движухи + + + для крафта напоминалки нужен текст + + + вас понял, упоминание будет {0} - - автоматом стартить квесты + + шизоид у меня на часах такого нету + + + ответственный + + + {0} создает новое событие: + + + движуха произойдет {0} в канале {1} + + + движуха будет происходить с {0} до {1} в {2} + + + побольше о движухе + + + все это длилось `{0}` + + + движуха происходит в {0} + + + движуха происходит в {0} до {1} + + + этот шизоид уже лежит в бане + + + {0} раззабанен + + + {0} в муте + + + {0} в размуте + + + этого шизоида никто не мутил. + + + у нас такого шизоида нету... + + + {0} вышел с посторонней помощью + + + причина: {0} + + + до: {0} + + + этот шизоид УЖЕ замучился + + + от {0} + + + девелоперы: + + + страничка Boyfriend's Wiki: + + + немного о Boyfriend + + + скучный лого/эмбед дизайнер создавший Boyfriend's Wiki + + + ВАЖНЫЙ соучастник кодинг-стримов @Octol1ttle + + + САМЫЙ ВАЖНЫЙ чел написавший кода больше всех (99.99%) + + + напоминалка для {0} скрафченА - - для крафта напоминалки нужен текст + + напоминалка для {0} - - вас понял, упоминание будет <t:{0}:f> + + ты хотел чтоб я напомнил тебе {0} - - шизоид у меня на часах такого нету + + приколы Boyfriend + + + прикол редактирован + + + прикол сдох + + + стало diff --git a/ReplyEmojis.cs b/ReplyEmojis.cs deleted file mode 100644 index 3ccb6a6..0000000 --- a/ReplyEmojis.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Boyfriend; - -public static class ReplyEmojis { - public const string Success = ":white_check_mark:"; - public const string Error = ":x:"; - public const string MissingArgument = ":keyboard:"; - public const string InvalidArgument = ":construction:"; - public const string NoPermission = ":no_entry_sign:"; - public const string CantInteract = ":vertical_traffic_light:"; - public const string Help = ":page_facing_up:"; - public const string SettingsList = ":gear:"; - public const string SettingsSet = ":control_knobs:"; - public const string Ping = ":signal_strength:"; - public const string Banned = ":hammer:"; - public const string Kicked = ":police_car:"; - public const string Muted = ":mute:"; - public const string Unmuted = ":loud_sound:"; - public const string Reminder = ":alarm_clock:"; -} diff --git a/Services/GuildDataService.cs b/Services/GuildDataService.cs new file mode 100644 index 0000000..be873f1 --- /dev/null +++ b/Services/GuildDataService.cs @@ -0,0 +1,109 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Boyfriend.Data; +using Microsoft.Extensions.Hosting; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Rest.Core; + +namespace Boyfriend.Services; + +/// +/// Handles saving, loading, initializing and providing . +/// +public class GuildDataService : IHostedService { + private readonly ConcurrentDictionary _datas = new(); + private readonly IDiscordRestGuildAPI _guildApi; + + // https://github.com/dotnet/aspnetcore/issues/39139 + public GuildDataService( + IHostApplicationLifetime lifetime, IDiscordRestGuildAPI guildApi) { + _guildApi = guildApi; + lifetime.ApplicationStopping.Register(ApplicationStopping); + } + + public Task StartAsync(CancellationToken ct) { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct) { + return Task.CompletedTask; + } + + private void ApplicationStopping() { + SaveAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + + private async Task SaveAsync(CancellationToken ct) { + var tasks = new List(); + foreach (var data in _datas.Values) { + await using var configStream = File.OpenWrite(data.ConfigurationPath); + tasks.Add(JsonSerializer.SerializeAsync(configStream, data.Configuration, cancellationToken: ct)); + + await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath); + tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct)); + + foreach (var memberData in data.MemberData.Values) { + await using var memberDataStream = File.OpenWrite($"{data.MemberDataPath}/{memberData.Id}.json"); + tasks.Add(JsonSerializer.SerializeAsync(memberDataStream, memberData, cancellationToken: ct)); + } + } + + await Task.WhenAll(tasks); + } + + public async Task GetData(Snowflake guildId, CancellationToken ct = default) { + return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct); + } + + private async Task InitializeData(Snowflake guildId, CancellationToken ct = default) { + var idString = $"{guildId}"; + var memberDataPath = $"{guildId}/MemberData"; + var configurationPath = $"{guildId}/Configuration.json"; + var scheduledEventsPath = $"{guildId}/ScheduledEvents.json"; + if (!Directory.Exists(idString)) Directory.CreateDirectory(idString); + if (!Directory.Exists(memberDataPath)) Directory.CreateDirectory(memberDataPath); + if (!File.Exists(configurationPath)) await File.WriteAllTextAsync(configurationPath, "{}", ct); + if (!File.Exists(scheduledEventsPath)) await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct); + + await using var configurationStream = File.OpenRead(configurationPath); + var configuration + = JsonSerializer.DeserializeAsync( + configurationStream, cancellationToken: ct); + + await using var eventsStream = File.OpenRead(scheduledEventsPath); + var events + = JsonSerializer.DeserializeAsync>( + eventsStream, cancellationToken: ct); + + var memberData = new Dictionary(); + foreach (var dataPath in Directory.GetFiles(memberDataPath)) { + await using var dataStream = File.OpenRead(dataPath); + var data = await JsonSerializer.DeserializeAsync(dataStream, cancellationToken: ct); + if (data is null) continue; + var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToDiscordSnowflake(), ct); + if (memberResult.IsSuccess) + data.Roles = memberResult.Entity.Roles.ToList(); + + memberData.Add(data.Id, data); + } + + var finalData = new GuildData( + await configuration ?? new GuildConfiguration(), configurationPath, + await events ?? new Dictionary(), scheduledEventsPath, + memberData, memberDataPath); + while (!_datas.ContainsKey(guildId)) _datas.TryAdd(guildId, finalData); + return finalData; + } + + public async Task GetConfiguration(Snowflake guildId, CancellationToken ct = default) { + return (await GetData(guildId, ct)).Configuration; + } + + public async Task GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) { + return (await GetData(guildId, ct)).GetMemberData(userId); + } + + public ICollection GetGuildIds() { + return _datas.Keys; + } +} diff --git a/Services/GuildUpdateService.cs b/Services/GuildUpdateService.cs new file mode 100644 index 0000000..3d55f07 --- /dev/null +++ b/Services/GuildUpdateService.cs @@ -0,0 +1,389 @@ +using Boyfriend.Data; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Gateway.Commands; +using Remora.Discord.API.Objects; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Discord.Gateway; +using Remora.Discord.Gateway.Responders; +using Remora.Discord.Interactivity; +using Remora.Rest.Core; +using Remora.Results; + +namespace Boyfriend.Services; + +/// +/// Handles executing guild updates (also called "ticks") once per second. +/// +public class GuildUpdateService : BackgroundService { + private static readonly (string Name, TimeSpan Duration)[] SongList = { + ("UNDEAD CORPORATION - The Empress", new TimeSpan(0, 4, 34)), + ("UNDEAD CORPORATION - Everything will freeze", new TimeSpan(0, 3, 17)), + ("Splatoon 3 - Rockagilly Blues (Yoko & the Gold Bazookas)", new TimeSpan(0, 3, 37)), + ("Splatoon 3 - Seep and Destroy", new TimeSpan(0, 2, 42)), + ("IA - A Tale of Six Trillion Years and a Night", new TimeSpan(0, 3, 40)), + ("Manuel - Gas Gas Gas", new TimeSpan(0, 3, 17)), + ("Camellia - Flamewall", new TimeSpan(0, 6, 50)) + }; + + private readonly List _activityList = new(1) { new Activity("with Remora.Discord", ActivityType.Game) }; + + private readonly IDiscordRestChannelAPI _channelApi; + private readonly DiscordGatewayClient _client; + private readonly GuildDataService _dataService; + private readonly IDiscordRestGuildScheduledEventAPI _eventApi; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly ILogger _logger; + private readonly IDiscordRestUserAPI _userApi; + private readonly UtilityService _utility; + + private DateTimeOffset _nextSongAt = DateTimeOffset.MinValue; + private uint _nextSongIndex; + + public GuildUpdateService( + IDiscordRestChannelAPI channelApi, DiscordGatewayClient client, GuildDataService dataService, + IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, ILogger logger, + IDiscordRestUserAPI userApi, UtilityService utility) { + _channelApi = channelApi; + _client = client; + _dataService = dataService; + _eventApi = eventApi; + _guildApi = guildApi; + _logger = logger; + _userApi = userApi; + _utility = utility; + } + + /// + /// Activates a periodic timer with a 1 second interval and adds guild update tasks on each timer tick. + /// Additionally, updates the current presence with songs from . + /// + /// If update tasks take longer than 1 second, the next timer tick will be skipped. + /// The cancellation token for this operation. + protected override async Task ExecuteAsync(CancellationToken ct) { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + var tasks = new List(); + + while (await timer.WaitForNextTickAsync(ct)) { + var guildIds = _dataService.GetGuildIds(); + if (guildIds.Count > 0 && DateTimeOffset.UtcNow >= _nextSongAt) { + var nextSong = SongList[_nextSongIndex]; + _activityList[0] = new Activity(nextSong.Name, ActivityType.Listening); + _client.SubmitCommand( + new UpdatePresence( + UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList)); + _nextSongAt = DateTimeOffset.UtcNow.Add(nextSong.Duration); + _nextSongIndex++; + if (_nextSongIndex >= SongList.Length) _nextSongIndex = 0; + } + + tasks.AddRange(guildIds.Select(id => TickGuildAsync(id, ct))); + + await Task.WhenAll(tasks); + tasks.Clear(); + } + } + + /// + /// Runs an update ("tick") for a guild with the provided . + /// + /// + /// This method does the following: + /// + /// Automatically unbans users once their ban period has expired. + /// Automatically grants members the guild's if one is set. + /// Sends reminders about an upcoming scheduled event. + /// Automatically starts scheduled events if is enabled. + /// Sends scheduled event start notifications. + /// Sends scheduled event completion notifications. + /// Sends reminders to members. + /// + /// This is done here and not in a for the following reasons: + /// + /// + /// Downtime would affect the reliability of notifications and automatic unbans if this logic were to be in a + /// . + /// + /// The Discord API doesn't provide necessary information about scheduled event updates. + /// + /// + /// The ID of the guild to update. + /// The cancellation token for this operation. + private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) { + var data = await _dataService.GetData(guildId, ct); + Messages.Culture = data.Culture; + var defaultRoleSnowflake = data.Configuration.DefaultRole.ToDiscordSnowflake(); + + foreach (var memberData in data.MemberData.Values) { + var userId = memberData.Id.ToDiscordSnowflake(); + + if (defaultRoleSnowflake.Value is not 0 && !memberData.Roles.Contains(defaultRoleSnowflake)) + _ = _guildApi.AddGuildMemberRoleAsync( + guildId, userId, defaultRoleSnowflake, ct: ct); + + if (DateTimeOffset.UtcNow > memberData.BannedUntil) { + var unbanResult = await _guildApi.RemoveGuildBanAsync( + guildId, userId, Messages.PunishmentExpired.EncodeHeader(), ct); + if (unbanResult.IsSuccess) + memberData.BannedUntil = null; + else + _logger.LogWarning( + "Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message); + } + + var userResult = await _userApi.GetUserAsync(userId, ct); + if (!userResult.IsDefined(out var user)) continue; + + for (var i = memberData.Reminders.Count - 1; i >= 0; i--) { + var reminder = memberData.Reminders[i]; + if (DateTimeOffset.UtcNow < reminder.RemindAt) continue; + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.Reminder, user.GetTag()), user) + .WithDescription( + string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text))) + .WithColour(ColorsList.Magenta) + .Build(); + + if (!embed.IsDefined(out var built)) continue; + + var messageResult = await _channelApi.CreateMessageAsync( + reminder.Channel, Mention.User(user), embeds: new[] { built }, ct: ct); + if (!messageResult.IsSuccess) + _logger.LogWarning( + "Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message); + + memberData.Reminders.Remove(reminder); + } + } + + var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); + if (!eventsResult.IsDefined(out var events)) return; + + if (data.Configuration.EventNotificationChannel is 0) return; + + foreach (var scheduledEvent in events) { + if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) { + data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status)); + } else { + var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; + if (storedEvent.Status == scheduledEvent.Status) { + if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) { + if (data.Configuration.AutoStartEvents + && scheduledEvent.Status is not GuildScheduledEventStatus.Active) { + var startResult = await _eventApi.ModifyGuildScheduledEventAsync( + guildId, scheduledEvent.ID, + status: GuildScheduledEventStatus.Active, ct: ct); + if (!startResult.IsSuccess) + _logger.LogWarning( + "Error in automatic scheduled event start request.\n{ErrorMessage}", + startResult.Error.Message); + } + } else if (data.Configuration.EventEarlyNotificationOffset != TimeSpan.Zero + && !storedEvent.EarlyNotificationSent + && DateTimeOffset.UtcNow + >= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset) { + var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct); + if (earlyResult.IsSuccess) + storedEvent.EarlyNotificationSent = true; + else + _logger.LogWarning( + "Error in scheduled event early notification sender.\n{ErrorMessage}", + earlyResult.Error.Message); + } + + continue; + } + + storedEvent.Status = scheduledEvent.Status; + } + + var result = scheduledEvent.Status switch { + GuildScheduledEventStatus.Scheduled => + await SendScheduledEventCreatedMessage(scheduledEvent, data.Configuration, ct), + GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed => + await SendScheduledEventUpdatedMessage(scheduledEvent, data, false, ct), + _ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))) + }; + + if (!result.IsSuccess) + _logger.LogWarning("Error in guild update.\n{ErrorMessage}", result.Error.Message); + } + } + + /// + /// Handles sending a notification, mentioning the if one is + /// set, + /// when a scheduled event is created + /// in a guild's if one is set. + /// + /// The scheduled event that has just been created. + /// The configuration of the guild containing the scheduled event. + /// The cancellation token for this operation. + /// A notification sending result which may or may not have succeeded. + private async Task SendScheduledEventCreatedMessage( + IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) { + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + + if (!scheduledEvent.CreatorID.IsDefined(out var creatorId)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID))); + var creatorResult = await _userApi.GetUserAsync(creatorId.Value, ct); + if (!creatorResult.IsDefined(out var creator)) return Result.FromError(creatorResult); + + string embedDescription; + var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null } + ? scheduledEvent.Description.Value + : string.Empty; + switch (scheduledEvent.EntityType) { + case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice: + if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID))); + + embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote( + string.Format( + Messages.DescriptionLocalEventCreated, + Markdown.Timestamp(scheduledEvent.ScheduledStartTime), + Mention.Channel(channelId) + ))}"; + break; + case GuildScheduledEventEntityType.External: + if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata))); + if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime))); + if (!metadata.Location.IsDefined(out var location)) + return Result.FromError(new ArgumentNullError(nameof(metadata.Location))); + + embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote( + string.Format( + Messages.DescriptionExternalEventCreated, + Markdown.Timestamp(scheduledEvent.ScheduledStartTime), + Markdown.Timestamp(endTime), + Markdown.InlineCode(location) + ))}"; + break; + default: + return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))); + } + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator) + .WithTitle(scheduledEvent.Name) + .WithDescription(embedDescription) + .WithEventCover(scheduledEvent.ID, scheduledEvent.Image) + .WithUserFooter(currentUser) + .WithCurrentTimestamp() + .WithColour(ColorsList.White) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + var roleMention = config.EventNotificationRole is not 0 + ? Mention.Role(config.EventNotificationRole.ToDiscordSnowflake()) + : string.Empty; + + var button = new ButtonComponent( + ButtonComponentStyle.Primary, + Messages.EventDetailsButton, + new PartialEmoji(Name: "📋"), + CustomIDHelpers.CreateButtonIDWithState( + "scheduled-event-details", $"{scheduledEvent.GuildID}:{scheduledEvent.ID}") + ); + + return (Result)await _channelApi.CreateMessageAsync( + config.EventNotificationChannel.ToDiscordSnowflake(), roleMention, embeds: new[] { built }, + components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct); + } + + /// + /// Handles sending a notification, mentioning the s, + /// when a scheduled event is about to start, has started or completed + /// in a guild's if one is set. + /// + /// The scheduled event that is about to start, has started or completed. + /// The data for the guild containing the scheduled event. + /// Controls whether or not a reminder for the scheduled event should be sent instead of the event started/completed notification + /// The cancellation token for this operation + /// A reminder/notification sending result which may or may not have succeeded. + private async Task SendScheduledEventUpdatedMessage( + IGuildScheduledEvent scheduledEvent, GuildData data, bool early, CancellationToken ct = default) { + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + + var embed = new EmbedBuilder(); + string? content = null; + if (early) + embed.WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser) + .WithColour(ColorsList.Default); + else + switch (scheduledEvent.Status) { + case GuildScheduledEventStatus.Active: + data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow; + + string embedDescription; + switch (scheduledEvent.EntityType) { + case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice: + if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID))); + + embedDescription = string.Format( + Messages.DescriptionLocalEventStarted, + Mention.Channel(channelId) + ); + break; + case GuildScheduledEventEntityType.External: + if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata))); + if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime))); + if (!metadata.Location.IsDefined(out var location)) + return Result.FromError(new ArgumentNullError(nameof(metadata.Location))); + + embedDescription = string.Format( + Messages.DescriptionExternalEventStarted, + Markdown.InlineCode(location), + Markdown.Timestamp(endTime) + ); + break; + default: + return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))); + } + + var contentResult = await _utility.GetEventNotificationMentions( + scheduledEvent, data.Configuration, ct); + if (!contentResult.IsDefined(out content)) + return Result.FromError(contentResult); + + embed.WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name)) + .WithDescription(embedDescription) + .WithColour(ColorsList.Green); + break; + case GuildScheduledEventStatus.Completed: + embed.WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name)) + .WithDescription( + string.Format( + Messages.EventDuration, + DateTimeOffset.UtcNow.Subtract( + data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime + ?? scheduledEvent.ScheduledStartTime).ToString())) + .WithColour(ColorsList.Black); + + data.ScheduledEvents.Remove(scheduledEvent.ID.Value); + break; + case GuildScheduledEventStatus.Canceled: + case GuildScheduledEventStatus.Scheduled: + default: return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))); + } + + var result = embed.WithCurrentTimestamp().Build(); + + if (!result.IsDefined(out var built)) return Result.FromError(result); + + return (Result)await _channelApi.CreateMessageAsync( + data.Configuration.EventNotificationChannel.ToDiscordSnowflake(), + content ?? default(Optional), embeds: new[] { built }, ct: ct); + } +} diff --git a/Services/UtilityService.cs b/Services/UtilityService.cs new file mode 100644 index 0000000..b4ff6fb --- /dev/null +++ b/Services/UtilityService.cs @@ -0,0 +1,140 @@ +using System.Text; +using Boyfriend.Data; +using Microsoft.Extensions.Hosting; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; + +namespace Boyfriend.Services; + +/// +/// Provides utility methods that cannot be transformed to extension methods because they require usage +/// of some Discord APIs. +/// +public class UtilityService : IHostedService { + private readonly IDiscordRestGuildScheduledEventAPI _eventApi; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly IDiscordRestUserAPI _userApi; + + public UtilityService( + IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, IDiscordRestGuildScheduledEventAPI eventApi) { + _guildApi = guildApi; + _userApi = userApi; + _eventApi = eventApi; + } + + public Task StartAsync(CancellationToken ct) { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct) { + return Task.CompletedTask; + } + + /// + /// Checks whether or not a member can interact with another member + /// + /// The ID of the guild in which an operation is being performed. + /// The executor of the operation. + /// The target of the operation. + /// The operation. + /// The cancellation token for this operation. + /// + /// + /// A result which has succeeded with a null string if the member can interact with the target. + /// + /// A result which has succeeded with a non-null string containing the error message if the member cannot + /// interact with the target. + /// + /// A result which has failed if an error occurred during the execution of this method. + /// + /// + public async Task> CheckInteractionsAsync( + Snowflake guildId, Snowflake interacterId, Snowflake targetId, string action, CancellationToken ct = default) { + if (interacterId == targetId) + return Result.FromSuccess($"UserCannot{action}Themselves".Localized()); + + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result.FromError(currentUserResult); + if (currentUser.ID == targetId) + return Result.FromSuccess($"UserCannot{action}Bot".Localized()); + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct); + if (!guildResult.IsDefined(out var guild)) + return Result.FromError(guildResult); + if (targetId == guild.OwnerID) return Result.FromSuccess($"UserCannot{action}Owner".Localized()); + + var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct); + if (!targetMemberResult.IsDefined(out var targetMember)) + return Result.FromSuccess(null); + + var currentMemberResult = await _guildApi.GetGuildMemberAsync(guildId, currentUser.ID, ct); + if (!currentMemberResult.IsDefined(out var currentMember)) + return Result.FromError(currentMemberResult); + + var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct); + if (!rolesResult.IsDefined(out var roles)) + return Result.FromError(rolesResult); + + var targetRoles = roles.Where(r => targetMember.Roles.Contains(r.ID)).ToList(); + var botRoles = roles.Where(r => currentMember.Roles.Contains(r.ID)); + + var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position); + if (targetBotRoleDiff >= 0) + return Result.FromSuccess($"BotCannot{action}Target".Localized()); + + if (interacterId == guild.OwnerID) + return Result.FromSuccess(null); + + var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId, ct); + if (!interacterResult.IsDefined(out var interacter)) + return Result.FromError(interacterResult); + + var interacterRoles = roles.Where(r => interacter.Roles.Contains(r.ID)); + var targetInteracterRoleDiff + = targetRoles.MaxOrDefault(r => r.Position) - interacterRoles.MaxOrDefault(r => r.Position); + if (targetInteracterRoleDiff >= 0) + return Result.FromSuccess($"UserCannot{action}Target".Localized()); + + return Result.FromSuccess(null); + } + + /// + /// Gets the string mentioning all s related to a scheduled + /// event. + /// + /// + /// If the guild configuration enables , then the + /// will also be mentioned. + /// + /// + /// The scheduled event whose subscribers will be mentioned if the guild configuration enables + /// . + /// + /// The configuration of the guild containing the scheduled event + /// The cancellation token for this operation. + /// A result containing the string which may or may not have succeeded. + public async Task> GetEventNotificationMentions( + IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) { + var builder = new StringBuilder(); + var receivers = config.EventStartedReceivers; + var role = config.EventNotificationRole.ToDiscordSnowflake(); + var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync( + scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct); + if (!usersResult.IsDefined(out var users)) return Result.FromError(usersResult); + + if (receivers.Contains(GuildConfiguration.NotificationReceiver.Role) && role.Value is not 0) + builder.Append($"{Mention.Role(role)} "); + if (receivers.Contains(GuildConfiguration.NotificationReceiver.Interested)) + builder = users.Where( + user => { + if (!user.GuildMember.IsDefined(out var member)) return true; + return !member.Roles.Contains(role); + }) + .Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} ")); + return builder.ToString(); + } +} diff --git a/Utils.cs b/Utils.cs deleted file mode 100644 index 27c6ecb..0000000 --- a/Utils.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Reflection; -using System.Text; -using System.Text.RegularExpressions; -using Boyfriend.Data; -using Discord; -using Discord.Net; -using Discord.WebSocket; -using Humanizer; -using Humanizer.Localisation; - -namespace Boyfriend; - -public static partial class Utils { - public static readonly Dictionary CultureInfoCache = new() { - { "ru", new CultureInfo("ru-RU") }, - { "en", new CultureInfo("en-US") }, - { "mctaylors-ru", new CultureInfo("tt-RU") } - }; - - private static readonly Dictionary ReflectionMessageCache = new(); - - private static readonly AllowedMentions AllowRoles = new() { - AllowedTypes = AllowedMentionTypes.Roles - }; - - public static string GetBeep(int i = -1) { - return GetMessage($"Beep{(i < 0 ? Random.Shared.Next(3) + 1 : ++i)}"); - } - - public static string? Wrap(string? original, bool limitedSpace = false) { - if (original is null) return null; - var maxChars = limitedSpace ? 970 : 1940; - if (original.Length > maxChars) original = original[..maxChars]; - var style = original.Contains('\n') ? "```" : "`"; - return $"{style}{original}{(original.Equals("") ? " " : "")}{style}"; - } - - public static string MentionChannel(ulong id) { - return $"<#{id}>"; - } - - public static ulong ParseMention(string mention) { - return ulong.TryParse(NumbersOnlyRegex().Replace(mention, ""), out var id) ? id : 0; - } - - public static async Task SendDirectMessage(SocketUser user, string toSend) { - try { await user.SendMessageAsync(toSend); } catch (HttpException e) { - if (e.DiscordCode is not DiscordErrorCode.CannotSendMessageToUser) throw; - } - } - - public static async Task SilentSendAsync(SocketTextChannel? channel, string text, bool allowRoles = false) { - try { - if (channel is null || text.Length is 0 or > 2000) - throw new UnreachableException($"Message length is out of range: {text.Length}"); - - await channel.SendMessageAsync(text, false, null, null, allowRoles ? AllowRoles : AllowedMentions.None); - } catch (Exception e) { - await Boyfriend.Log( - new LogMessage( - LogSeverity.Error, nameof(Utils), - "Exception while silently sending message", e)); - } - } - - public static RequestOptions GetRequestOptions(string reason) { - var options = RequestOptions.Default; - options.AuditLogReason = reason; - return options; - } - - public static string GetMessage(string name) { - var propertyName = name; - name = $"{Messages.Culture}/{name}"; - if (ReflectionMessageCache.TryGetValue(name, out var cachedMessage)) return cachedMessage; - - var toReturn = - typeof(Messages).GetProperty(propertyName, BindingFlags.NonPublic | BindingFlags.Static)?.GetValue(null) - ?.ToString(); - if (toReturn is null) { - Console.Error.WriteLine($@"Could not find localized property: {propertyName}"); - return name; - } - - ReflectionMessageCache.Add(name, toReturn); - return toReturn; - } - - public static async Task - SendFeedbackAsync(string feedback, SocketGuild guild, string mention, bool sendPublic = false) { - var data = GuildData.Get(guild); - var adminChannel = data.PrivateFeedbackChannel; - var systemChannel = data.PublicFeedbackChannel; - var toSend = $"*[{mention}: {feedback}]*"; - if (adminChannel is not null) await SilentSendAsync(adminChannel, toSend); - if (sendPublic && systemChannel is not null) await SilentSendAsync(systemChannel, toSend); - } - - public static string GetHumanizedTimeSpan(TimeSpan span) { - return span.TotalSeconds < 1 - ? Messages.Ever - : $" {span.Humanize(2, minUnit: TimeUnit.Second, maxUnit: TimeUnit.Month, culture: Messages.Culture.Name.Contains("RU") ? CultureInfoCache["ru"] : Messages.Culture)}"; - } - - public static void SetCurrentLanguage(SocketGuild guild) { - Messages.Culture = CultureInfoCache[GuildData.Get(guild).Preferences["Lang"]]; - } - - public static void SafeAppendToBuilder(StringBuilder appendTo, string appendWhat, SocketTextChannel? channel) { - if (channel is null) return; - if (appendTo.Length + appendWhat.Length > 2000) { - _ = SilentSendAsync(channel, appendTo.ToString()); - appendTo.Clear(); - } - - appendTo.AppendLine(appendWhat); - } - - public static void SafeAppendToBuilder(StringBuilder appendTo, string appendWhat, SocketUserMessage message) { - if (appendTo.Length + appendWhat.Length > 2000) { - _ = message.ReplyAsync(appendTo.ToString(), false, null, AllowedMentions.None); - appendTo.Clear(); - } - - appendTo.AppendLine(appendWhat); - } - - public static SocketTextChannel? GetEventNotificationChannel(SocketGuild guild) { - return guild.GetTextChannel( - ParseMention( - GuildData.Get(guild) - .Preferences["EventNotificationChannel"])); - } - - public static bool UserExists(ulong id) { - return Boyfriend.Client.GetUser(id) is not null || UserInMemberData(id); - } - - private static bool UserInMemberData(ulong id) { - return GuildData.GuildDataDictionary.Values.Any(gData => gData.MemberData.Values.Any(mData => mData.Id == id)); - } - - public static async Task UnmuteMemberAsync( - GuildData data, string modDiscrim, SocketGuildUser toUnmute, - string reason) { - var requestOptions = GetRequestOptions($"({modDiscrim}) {reason}"); - var role = data.MuteRole; - - if (role is not null) { - if (!toUnmute.Roles.Contains(role)) return false; - if (data.Preferences["RemoveRolesOnMute"] is "true") - await toUnmute.AddRolesAsync(data.MemberData[toUnmute.Id].Roles, requestOptions); - await toUnmute.RemoveRoleAsync(role, requestOptions); - data.MemberData[toUnmute.Id].MutedUntil = null; - } else { - if (toUnmute.TimedOutUntil is null || toUnmute.TimedOutUntil.Value < DateTimeOffset.UtcNow) return false; - - await toUnmute.RemoveTimeOutAsync(requestOptions); - } - - return true; - } - - [GeneratedRegex("[^0-9]")] - private static partial Regex NumbersOnlyRegex(); -} diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0b5e0cd --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via the "Report Content" feature or via email at +l1ttleofficial@outlook.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..bcaa25f --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing Guidelines + +Thank you for showing interest in the development of Boyfriend. We aim to provide a good collaborating environment for +everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. +Before starting, please read our [Code of Conduct](CODE_OF_CONDUCT.md) + +## Reporting bugs + +A **bug** is a situation in which there is something clearly wrong with the bot. Examples of applicable bug reports are: + +- The bot doesn't reply to a command +- The bot sends the same message twice +- The bot takes a long time to a respond if I use this specific command +- An embed the bot sent has incorrect information in it + +To track bug reports, we primarily use GitHub **issues**. When opening an issue, please keep in mind the following: + +- Before opening the issue, please search for any similar existing issues using the text search bar and the issue + labels. This includes both open and closed issues (we may have already fixed something, but the fix hasn't yet been + released). +- When opening the issue, please fill out as much of the issue template as you can. In particular, please make sure to + include console output and screenshots as much as possible. +- We may ask you for follow-up information to reproduce or debug the problem. Please look out for this and provide + follow-up info if we request it. + +## Submitting pull requests + +While pull requests from unaffiliated contributors are welcome, please note that the core team *may* be focused on +internal issues that haven't been published to the issue tracker yet. Reviewing PRs is done on a best-effort basis, so +please be aware that it may take a while before a core maintainer gets around to review your change. + +The [issue tracker](https://github.com/TeamOctolings/Boyfriend/issues) should provide plenty of issues to start with. +Make sure to check that an issue you're planning to resolve does not already have people working on it and that there +are no PRs associated with it + +In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't +seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the +correct direction on how to address it. + +If you'd like to propose a subjective change to one of the UI/UX aspects of the bot, or there is a bigger task you'd +like to work on, but there is no corresponding issue yet for it, **please open an issue first** to avoid wasted effort. + +Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes: + +- Make sure you're comfortable with the principles of object-oriented programming, the syntax of C\# and your + development environment. +- Make sure you are familiar with [git](https://git-scm.com/) + and [the pull request workflow](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests). +- Please do not make code changes via the GitHub web interface. +- Please make sure your development environment respects the .editorconfig file present in the repository. Our code + style differs from most C\# projects and is closer to something you see in Java projects. +- Please test your changes. We expect most new features and bugfixes to be tested in an environment similar to + production. + +After you're done with your changes and you wish to open the PR, please observe the following recommendations: + +- Please submit the pull request from + a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and + keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary. +- Please avoid pushing untested or incomplete code. +- Please do not force-push or rebase unless we ask you to. +- Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change + is ready for merge. + +We are highly committed to quality when it comes to Boyfriend. This means that contributions from less experienced +community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never +conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please +consider our comments and requests a learning experience. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..45f6a92 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,48 @@ + + + + + + + Boyfriend Logo + + + +![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master) +![GitHub last commit](https://img.shields.io/github/last-commit/TeamOctolings/Boyfriend) +![CodeFactor](https://img.shields.io/codefactor/grade/github/TeamOctolings/Boyfriend) + +Beep! I'm a general-purpose bot for moderation written by [@Octol1ttle](https://github.com/Octol1ttle) in C# and +Discord.Net + +## Features + +* Banning, muting, kicking, etc. +* Reminding you about something if you wish +* Reminding everyone about that new event you made +* Log everything from joining the server to deleting messages + +*...and more!* + +## Installing and running Boyfriend + +You can read our [wiki](https://github.com/TeamOctolings/Boyfriend/wiki) in order to assemble your Boyfriend™ and +moderate the server. + +## Contributing + +When it comes to contributing to the project, the two main things you can do to help out are reporting issues and +submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in +the most effective way possible. + +## Special Thanks + +![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg) + +[JetBrains](https://www.jetbrains.com/), creators of [ReSharper](https://www.jetbrains.com/resharper) +and [Rider](https://www.jetbrains.com/rider), supports Boyfriend with one of +their [Open Source Licenses](https://jb.gg/OpenSourceSupport). +Rider is the recommended IDE when working with Boyfriend, and most of the Boyfriend team uses it. +Additionally, ReSharper command-line tools made by JetBrains are used for status checks on pull requests to ensure code +quality even when not using ReSharper or Rider.