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 @@ -<picture> - - <source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png"> - - <source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/95250141/206895340-3415d97d-91fd-4fb6-8c17-4e1bf340e1df.png"> - - <img alt="Boyfriend Logo" src="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png"> - -</picture> - - - - - -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<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>()); - 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<SlashService>(); + // Providing a guild ID to this call will result in command duplicates! + // To get rid of them, provide the ID of the guild containing duplicates, + // comment out calls to WithCommandGroup in CreateHostBuilder + // then launch the bot again and remove the guild ID + await slashService.UpdateSlashCommandsAsync(); - 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<Task> 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<IConfiguration>(); - var now = DateTimeOffset.UtcNow; - foreach (var guild in Client.Guilds) GuildTickTasks.Add(TickGuildAsync(guild, now)); + return configuration.GetValue<string?>("BOT_TOKEN") + ?? throw new InvalidOperationException( + "No bot token has been provided. Set the " + + "BOT_TOKEN environment variable to a valid token."); + } + ).ConfigureServices( + (_, services) => { + services.Configure<DiscordGatewayClientOptions>( + options => options.Intents |= GatewayIntents.MessageContents + | GatewayIntents.GuildMembers + | GatewayIntents.GuildScheduledEvents); + services.Configure<CacheSettings>( + settings => { + settings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); + settings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); + settings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7)); + settings.SetSlidingExpiration<IMessage>(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<IConfigurationBuilder, ConfigurationBuilder>() + .AddDiscordCaching() + .AddDiscordCommands(true) + .AddPreparationErrorEvent<ErrorLoggingPreparationErrorEvent>() + .AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>() + .AddInteractivity() + .AddInteractionGroup<InteractionResponders>() + .AddSingleton<GuildDataService>() + .AddSingleton<UtilityService>() + .AddHostedService<GuildUpdateService>() + .AddCommandTree() + .WithCommandGroup<AboutCommandGroup>() + .WithCommandGroup<BanCommandGroup>() + .WithCommandGroup<ClearCommandGroup>() + .WithCommandGroup<KickCommandGroup>() + .WithCommandGroup<MuteCommandGroup>() + .WithCommandGroup<PingCommandGroup>() + .WithCommandGroup<RemindCommandGroup>() + .WithCommandGroup<SettingsCommandGroup>(); + 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 @@ <TargetFramework>net7.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> - <Version>1.0.0</Version> + <Version>2.0.0</Version> <Title>Boyfriend</Title> - <Authors>Octol1ttle, mctaylors</Authors> + <Authors>Octol1ttle, mctaylors, neroduckale</Authors> <Copyright>AGPLv3</Copyright> <PackageProjectUrl>https://github.com/TeamOctolings/Boyfriend</PackageProjectUrl> <PackageLicenseUrl>https://github.com/TeamOctolings/Boyfriend/blob/master/LICENSE</PackageLicenseUrl> @@ -19,8 +19,31 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Discord.Net" Version="3.10.0"/> - <PackageReference Include="Humanizer.Core" Version="2.14.1" /> - <PackageReference Include="Humanizer.Core.ru" Version="2.14.1" /> + <PackageReference Include="DiffPlex" Version="1.7.1" /> + <PackageReference Include="Humanizer.Core.ru" Version="2.14.1" /> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" /> + <PackageReference Include="Remora.Discord" Version="2023.3.0" /> + </ItemGroup> + + <!-- TODO: remove this when done --> + <ItemGroup> + <Compile Remove="old\**" /> + <Compile Update="Messages.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>Messages.resx</DependentUpon> + </Compile> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Remove="old\**" /> + <EmbeddedResource Update="Messages.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>Messages.Designer.cs</LastGenOutput> + </EmbeddedResource> + </ItemGroup> + + <ItemGroup> + <None Remove="old\**" /> </ItemGroup> </Project> 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; + +/// <summary> +/// Contains all colors used in embeds. +/// </summary> +public static class ColorsList { + public static readonly Color Default = Color.Gray; + public static readonly Color Red = Color.Firebrick; + public static readonly Color Green = Color.PaleGreen; + public static readonly Color Yellow = Color.Gold; + public static readonly Color Blue = Color.RoyalBlue; + public static readonly Color Magenta = Color.Orchid; + public static readonly Color Cyan = Color.LightSkyBlue; + public static readonly Color Black = Color.Black; + public static readonly Color White = Color.WhiteSmoke; +} 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<Task> _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; + +/// <summary> +/// Handles the command to show information about this bot: /about. +/// </summary> +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; + } + + /// <summary> + /// A slash command that shows information about this bot. + /// </summary> + /// <returns> + /// A feedback sending result which may or may not have succeeded. + /// </returns> + [Command("about")] + [Description("Shows Boyfriend's developers")] + public async Task<Result> 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; + +/// <summary> +/// Handles commands related to ban management: /ban and /unban. +/// </summary> +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; + } + + /// <summary> + /// A slash command that bans a Discord user with the specified reason. + /// </summary> + /// <param name="target">The user to ban.</param> + /// <param name="duration">The duration for this ban. The user will be automatically unbanned after this duration.</param> + /// <param name="reason"> + /// The reason for this ban. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to + /// <see cref="IDiscordRestGuildAPI.CreateGuildBanAsync" />. + /// </param> + /// <returns> + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was banned and vice-versa. + /// </returns> + /// <seealso cref="UnbanUserAsync" /> + [Command("ban", "бан")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.BanMembers)] + [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] + [Description("Ban user")] + public async Task<Result> 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<Embed> 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); + } + + /// <summary> + /// A slash command that unbans a Discord user with the specified reason. + /// </summary> + /// <param name="target">The user to unban.</param> + /// <param name="reason"> + /// The reason for this unban. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to + /// <see cref="IDiscordRestGuildAPI.RemoveGuildBanAsync" />. + /// </param> + /// <returns> + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was unbanned and vice-versa. + /// </returns> + /// <seealso cref="BanUserAsync" /> + /// <seealso cref="GuildUpdateService.TickGuildAsync"/> + [Command("unban")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.BanMembers)] + [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] + [Description("Unban user")] + public async Task<Result> 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; + +/// <summary> +/// Handles the command to clear messages in a channel: /clear. +/// </summary> +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; + } + + /// <summary> + /// A slash command that clears messages in the channel it was executed. + /// </summary> + /// <param name="amount">The amount of messages to clear.</param> + /// <returns> + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that any messages + /// were cleared and vice-versa. + /// </returns> + [Command("clear", "очистить")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.ManageMessages)] + [Description("Remove multiple messages")] + public async Task<Result> 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<Snowflake>(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; + +/// <summary> +/// Handles error logging for slash commands that couldn't be successfully prepared. +/// </summary> +public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent { + private readonly ILogger<ErrorLoggingPreparationErrorEvent> _logger; + + public ErrorLoggingPreparationErrorEvent(ILogger<ErrorLoggingPreparationErrorEvent> logger) { + _logger = logger; + } + + /// <summary> + /// Logs a warning using the injected <see cref="ILogger" /> if the <paramref name="preparationResult" /> has not + /// succeeded. + /// </summary> + /// <param name="context">The context of the slash command. Unused.</param> + /// <param name="preparationResult">The result whose success is checked.</param> + /// <param name="ct">The cancellation token for this operation. Unused.</param> + /// <returns>A result which has succeeded.</returns> + public Task<Result> PreparationFailed( + IOperationContext context, IResult preparationResult, CancellationToken ct = default) { + if (!preparationResult.IsSuccess) + _logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message); + + return Task.FromResult(Result.FromSuccess()); + } +} + +/// <summary> +/// Handles error logging for slash command groups. +/// </summary> +public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent { + private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger; + + public ErrorLoggingPostExecutionEvent(ILogger<ErrorLoggingPostExecutionEvent> logger) { + _logger = logger; + } + + /// <summary> + /// Logs a warning using the injected <see cref="ILogger" /> if the <paramref name="commandResult" /> has not + /// succeeded. + /// </summary> + /// <param name="context">The context of the slash command. Unused.</param> + /// <param name="commandResult">The result whose success is checked.</param> + /// <param name="ct">The cancellation token for this operation. Unused.</param> + /// <returns>A result which has succeeded.</returns> + public Task<Result> 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; + +/// <summary> +/// Handles the command to kick members of a guild: /kick. +/// </summary> +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; + } + + /// <summary> + /// A slash command that kicks a Discord user with the specified reason. + /// </summary> + /// <param name="target">The user to kick.</param> + /// <param name="reason"> + /// The reason for this kick. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to + /// <see cref="IDiscordRestGuildAPI.RemoveGuildMemberAsync" />. + /// </param> + /// <returns> + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was kicked and vice-versa. + /// </returns> + [Command("kick", "кик")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.KickMembers)] + [RequireBotDiscordPermissions(DiscordPermission.KickMembers)] + [Description("Kick member")] + public async Task<Result> 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<Embed> 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; + +/// <summary> +/// Handles commands related to mute management: /mute and /unmute. +/// </summary> +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; + } + + /// <summary> + /// A slash command that mutes a Discord user with the specified reason. + /// </summary> + /// <param name="target">The user to mute.</param> + /// <param name="duration">The duration for this mute. The user will be automatically unmuted after this duration.</param> + /// <param name="reason"> + /// The reason for this mute. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to + /// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />. + /// </param> + /// <returns> + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was muted and vice-versa. + /// </returns> + /// <seealso cref="UnmuteUserAsync" /> + [Command("mute", "мут")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ModerateMembers)] + [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] + [Description("Mute member")] + public async Task<Result> 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<Embed> 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); + } + + /// <summary> + /// A slash command that unmutes a Discord user with the specified reason. + /// </summary> + /// <param name="target">The user to unmute.</param> + /// <param name="reason"> + /// The reason for this unmute. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to + /// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />. + /// </param> + /// <returns> + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was unmuted and vice-versa. + /// </returns> + /// <seealso cref="MuteUserAsync" /> + /// <seealso cref="GuildUpdateService.TickGuildAsync"/> + [Command("unmute", "размут")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ModerateMembers)] + [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] + [Description("Unmute member")] + public async Task<Result> 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; + +/// <summary> +/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping +/// </summary> +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; + } + + /// <summary> + /// A slash command that shows time taken for the gateway to respond to the last heartbeat. + /// </summary> + /// <returns> + /// A feedback sending result which may or may not have succeeded. + /// </returns> + [Command("ping", "пинг")] + [Description("Get bot latency")] + public async Task<Result> 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; + +/// <summary> +/// Handles the command to manage reminders: /remind +/// </summary> +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<Result> 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; + +/// <summary> +/// Handles the commands to list and modify per-guild settings: /settings and /settings list. +/// </summary> +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; + } + + /// <summary> + /// A slash command that lists current per-guild settings. + /// </summary> + /// <returns> + /// A feedback sending result which may or may not have succeeded. + /// </returns> + [Command("settings list")] + [Description("Shows settings list for this server")] + [SuppressInteractionResponse(suppress: true)] + public async Task<Result> 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<GuildConfiguration.NotificationReceiver>)) { + var list = (something as List<GuildConfiguration.NotificationReceiver>); + 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)); + } + + /// <summary> + /// A slash command that modifies per-guild settings. + /// </summary> + /// <returns> + /// A feedback sending result which may or may not have succeeded. + /// </returns> + [Command("settings")] + [Description("Change settings for this server")] + public async Task<Result> 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; + +/// <summary> +/// Stores per-guild settings that can be set by a member +/// with <see cref="DiscordPermission.ManageGuild" /> using the /settings command +/// </summary> +public class GuildConfiguration { + /// <summary> + /// Represents a scheduled event notification receiver. + /// </summary> + /// <remarks> + /// Used to selectively mention guild members when a scheduled event has started or is about to start. + /// </remarks> + public enum NotificationReceiver { + Interested, + Role + } + + public static readonly Dictionary<string, CultureInfo> CultureInfoCache = new() { + { "en", new CultureInfo("en-US") }, + { "ru", new CultureInfo("ru-RU") }, + { "mctaylors-ru", new CultureInfo("tt-RU") } + }; + + public string Language { get; set; } = "en"; + + /// <summary> + /// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the server. + /// </summary> + /// <remarks> + /// <list type="bullet"> + /// <item>No message will be sent if set to "off", "disable" or "disabled".</item> + /// <item><see cref="Messages.DefaultWelcomeMessage" /> will be sent if set to "default" or "reset"</item> + /// </list> + /// </remarks> + /// <seealso cref="GuildMemberAddResponder" /> + public string WelcomeMessage { get; set; } = "default"; + + /// <summary> + /// Controls whether or not the <see cref="Messages.Ready" /> message should be sent + /// in <see cref="PrivateFeedbackChannel" /> on startup. + /// </summary> + /// <seealso cref="GuildCreateResponder" /> + public bool ReceiveStartupMessages { get; set; } + + public bool RemoveRolesOnMute { get; set; } + + /// <summary> + /// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back. + /// </summary> + /// <remarks>Roles will not be returned if the member left the guild because of /ban or /kick.</remarks> + public bool ReturnRolesOnRejoin { get; set; } + + public bool AutoStartEvents { get; set; } + + /// <summary> + /// Controls what channel should all public messages be sent to. + /// </summary> + public ulong PublicFeedbackChannel { get; set; } + + /// <summary> + /// Controls what channel should all private, moderator-only messages be sent to. + /// </summary> + 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; } + + /// <summary> + /// Controls what guild members should be mentioned when a scheduled event has started or is about to start. + /// </summary> + /// <seealso cref="NotificationReceiver" /> + public List<NotificationReceiver> EventStartedReceivers { get; set; } + = new() { NotificationReceiver.Interested, NotificationReceiver.Role }; + + /// <summary> + /// Controls the amount of time before a scheduled event to send a reminder in <see cref="EventNotificationChannel" />. + /// </summary> + 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<string, string> 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<ulong, GuildData> GuildDataDictionary = new(); - - private static readonly JsonSerializerOptions Options = new() { - IncludeFields = true, - WriteIndented = true - }; - - private readonly string _configurationFile; - - private readonly ulong _id; - - public readonly List<ulong> EarlyNotifications = new(); +/// <summary> +/// Stores information about a guild. This information is not accessible via the Discord API. +/// </summary> +/// <remarks>This information is stored on disk as a JSON file.</remarks> +public class GuildData { + public readonly GuildConfiguration Configuration; + public readonly string ConfigurationPath; public readonly Dictionary<ulong, MemberData> MemberData; + public readonly string MemberDataPath; - public readonly Dictionary<string, string> Preferences; + public readonly Dictionary<ulong, ScheduledEventData> 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<Dictionary<string, string>>(File.ReadAllText(_configurationFile)) - ?? new Dictionary<string, string>(); - - 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<ulong, MemberData>(); - foreach (var data in Directory.GetFiles(memberDataDir)) { - var deserialised - = JsonSerializer.Deserialize<MemberData>(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<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath, + Dictionary<ulong, MemberData> 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<DateTimeOffset> JoinedAt; - public List<DateTimeOffset> LeftAt; - public DateTimeOffset? MutedUntil; - public List<Reminder> Reminders; - public List<ulong> Roles; - - [JsonConstructor] - public MemberData(DateTimeOffset? bannedUntil, ulong id, bool isInGuild, List<DateTimeOffset> joinedAt, - List<DateTimeOffset> leftAt, DateTimeOffset? mutedUntil, List<Reminder> reminders, List<ulong> roles) { - BannedUntil = bannedUntil; +/// <summary> +/// Stores information about a member +/// </summary> +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<DateTimeOffset> { user.JoinedAt!.Value }; - LeftAt = new List<DateTimeOffset>(); - Roles = user.RoleIds.ToList(); - Roles.Remove(user.Guild.Id); - Reminders = new List<Reminder>(); - } + public ulong Id { get; } + public DateTimeOffset? BannedUntil { get; set; } + public List<Snowflake> Roles { get; set; } = new(); + public List<Reminder> 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; + +/// <summary> +/// Stores information about scheduled events. This information is not provided by the Discord API. +/// </summary> +/// <remarks>This information is stored on disk as a JSON file.</remarks> +public 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<SocketGuildUser, ulong> 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<IMessage, ulong> message, - Cacheable<IMessageChannel, ulong> 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<IMessage, ulong> 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; + +/// <summary> +/// Handles sending a <see cref="Messages.Ready" /> message to a guild that has just initialized if that guild +/// has <see cref="GuildConfiguration.ReceiveStartupMessages" /> enabled +/// </summary> +public class GuildCreateResponder : IResponder<IGuildCreate> { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly ILogger<GuildCreateResponder> _logger; + private readonly IDiscordRestUserAPI _userApi; + + public GuildCreateResponder( + IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger<GuildCreateResponder> logger, + IDiscordRestUserAPI userApi) { + _channelApi = channelApi; + _dataService = dataService; + _logger = logger; + _userApi = userApi; + } + + public async Task<Result> 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); + } +} + +/// <summary> +/// Handles logging the contents of a deleted message and the user who deleted the message +/// to a guild's <see cref="GuildConfiguration.PrivateFeedbackChannel" /> if one is set. +/// </summary> +public class MessageDeletedResponder : IResponder<IMessageDelete> { + 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<Result> 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); + } +} + +/// <summary> +/// Handles logging the difference between an edited message's old and new content +/// to a guild's <see cref="GuildConfiguration.PrivateFeedbackChannel" /> if one is set. +/// </summary> +public class MessageEditedResponder : IResponder<IMessageUpdate> { + 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<Result> 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<IMessage>( + 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<IMessage>(cacheKey, ct); + // However, since we evicted the message, subsequent edits won't have a cached instance to work with + // Getting the message will put it back in the cache, resolving all issues + // We don't need to await this since the result is not needed + // NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages + // NOTE: Awaiting this might not even solve this if the same responder is called asynchronously + _ = _channelApi.GetChannelMessageAsync(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); + } +} + +/// <summary> +/// Handles sending a guild's <see cref="GuildConfiguration.WelcomeMessage" /> if one is set. +/// If <see cref="GuildConfiguration.ReturnRolesOnRejoin"/> is enabled, roles will be returned. +/// </summary> +/// <seealso cref="GuildConfiguration.WelcomeMessage" /> +public class GuildMemberAddResponder : IResponder<IGuildMemberAdd> { + 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<Result> 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); + } +} + +/// <summary> +/// Handles sending a notification when a scheduled event has been cancelled +/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set. +/// </summary> +public class GuildScheduledEventDeleteResponder : IResponder<IGuildScheduledEventDelete> { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + + public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) { + _channelApi = channelApi; + _dataService = dataService; + } + + public async Task<Result> 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); + } +} + +/// <summary> +/// Handles updating <see cref="MemberData.Roles" /> when a guild member is updated. +/// </summary> +public class GuildMemberUpdateResponder : IResponder<IGuildMemberUpdate> { + private readonly GuildDataService _dataService; + + public GuildMemberUpdateResponder(GuildDataService dataService) { + _dataService = dataService; + } + + public async Task<Result> 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(); + } +} + +/// <summary> +/// Handles sending replies to easter egg messages. +/// </summary> +public class MessageCreateResponder : IResponder<IMessageCreate> { + private readonly IDiscordRestChannelAPI _channelApi; + + public MessageCreateResponder(IDiscordRestChannelAPI channelApi) { + _channelApi = channelApi; + } + + public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) { + _ = _channelApi.CreateMessageAsync( + gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content switch { + "whoami" => "`nobody`", + "сука !!" => "`root`", + "воооо" => "`removing /...`", + "пон" => + "https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg", + "++++" => "#", + _ => default(Optional<string>) + }); + 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 { + /// <summary> + /// Adds a footer with the <paramref name="user" />'s avatar and tag (@username or username#0000). + /// </summary> + /// <param name="builder">The builder to add the footer to.</param> + /// <param name="user">The user whose tag and avatar to add.</param> + /// <returns>The builder with the added footer.</returns> + 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)); + } + + /// <summary> + /// Adds a footer representing that an action was performed by a <paramref name="user" />. + /// </summary> + /// <param name="builder">The builder to add the footer to.</param> + /// <param name="user">The user that performed the action whose tag and avatar to use.</param> + /// <returns>The builder with the added footer.</returns> + public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user) { + var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256); + var avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity.AbsoluteUri + : CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri; + + return builder.WithFooter( + new EmbedFooter($"{Messages.IssuedBy}:\n{user.GetTag()}", avatarUrl)); + } + + /// <summary> + /// Adds a title using the author field, making it smaller than using the title field. + /// </summary> + /// <param name="builder">The builder to add the small title to.</param> + /// <param name="text">The text of the small title.</param> + /// <param name="avatarSource">The user whose avatar to use in the small title.</param> + /// <param name="url">The URL that will be opened if a user clicks on the small title.</param> + /// <returns>The builder with the added small title in the author field.</returns> + public static EmbedBuilder WithSmallTitle( + this EmbedBuilder builder, string text, IUser? avatarSource = null, 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; + } + + /// <summary> + /// Adds a footer representing that the action was performed in the <paramref name="guild" />. + /// </summary> + /// <param name="builder">The builder to add the footer to.</param> + /// <param name="guild">The guild whose name and icon to use.</param> + /// <returns>The builder with the added footer.</returns> + public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild) { + var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); + var iconUrl = iconUrlResult.IsSuccess + ? iconUrlResult.Entity.AbsoluteUri + : default(Optional<string>); + + return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl)); + } + + /// <summary> + /// Adds a title representing that the action happened in the <paramref name="guild" />. + /// </summary> + /// <param name="builder">The builder to add the title to.</param> + /// <param name="guild">The guild whose name and icon to use.</param> + /// <returns>The builder with the added title.</returns> + public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild) { + var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); + var iconUrl = iconUrlResult.IsSuccess + ? iconUrlResult.Entity.AbsoluteUri + : null; + + builder.Author = new EmbedAuthorBuilder(guild.Name, iconUrl: iconUrl); + return builder; + } + + /// <summary> + /// Adds a scheduled event's cover image. + /// </summary> + /// <param name="builder">The builder to add the image to.</param> + /// <param name="eventId">The ID of the scheduled event whose image to use.</param> + /// <param name="imageHashOptional">The Optional containing the image hash.</param> + /// <returns>The builder with the added cover image.</returns> + public static EmbedBuilder WithEventCover( + this EmbedBuilder builder, Snowflake eventId, Optional<IImageHash?> imageHashOptional) { + if (!imageHashOptional.IsDefined(out var imageHash)) return builder; + + var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024); + return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder; + } + + /// <summary> + /// Sanitizes a string for use in <see cref="Markdown.BlockCode(string)" /> by inserting zero-width spaces in between + /// symbols used to format the string with block code. + /// </summary> + /// <param name="s">The string to sanitize.</param> + /// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns> + private static string SanitizeForBlockCode(this string s) { + return s.Replace("```", "```"); + } + + /// <summary> + /// Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string with block code. + /// </summary> + /// <param name="s">The string to sanitize and format.</param> + /// <returns>The sanitized string formatted with <see cref="Markdown.BlockCode(string)" />.</returns> + 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; + } + + /// <summary> + /// Encodes a string to allow its transmission in request headers. + /// </summary> + /// <remarks>Used when encountering "Request headers must contain only ASCII characters".</remarks> + /// <param name="s">The string to encode.</param> + /// <returns>An encoded string with spaces kept intact.</returns> + public static string EncodeHeader(this string s) { + return WebUtility.UrlEncode(s).Replace('+', ' '); + } + + 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<TSource, TResult>( + this IEnumerable<TSource> source, Func<TSource, TResult> 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; + +/// <summary> +/// Handles responding to various interactions. +/// </summary> +public class InteractionResponders : InteractionGroup { + private readonly FeedbackService _feedbackService; + + public InteractionResponders(FeedbackService feedbackService) { + _feedbackService = feedbackService; + } + + /// <summary> + /// A button that will output an ephemeral embed containing the information about a scheduled event. + /// </summary> + /// <param name="state">The ID of the guild and scheduled event, encoded as "guildId:eventId".</param> + /// <returns>An ephemeral feedback sending result which may or may not have succeeded.</returns> + [Button("scheduled-event-details")] + public async Task<Result> 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; - - + + /// <summary> /// A strongly-typed resource class, for looking up localized strings, etc. /// </summary> @@ -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() { } - + /// <summary> /// Returns the cached ResourceManager instance used by this class. /// </summary> @@ -44,7 +44,7 @@ namespace Boyfriend { return resourceMan; } } - + /// <summary> /// 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; } } - + /// <summary> - /// Looks up a localized string similar to Bah! . + /// Looks up a localized string similar to About Boyfriend. + /// </summary> + internal static string AboutBot { + get { + return ResourceManager.GetString("AboutBot", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to logo and embed designer, Boyfriend's Wiki creator. + /// </summary> + internal static string AboutDeveloper_mctaylors { + get { + return ResourceManager.GetString("AboutDeveloper@mctaylors", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to developer. + /// </summary> + internal static string AboutDeveloper_neroduckale { + get { + return ResourceManager.GetString("AboutDeveloper@neroduckale", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to main developer. + /// </summary> + internal static string AboutDeveloper_Octol1ttle { + get { + return ResourceManager.GetString("AboutDeveloper@Octol1ttle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Developers:. + /// </summary> + internal static string AboutTitleDevelopers { + get { + return ResourceManager.GetString("AboutTitleDevelopers", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Boyfriend's Wiki Page:. + /// </summary> + internal static string AboutTitleWiki { + get { + return ResourceManager.GetString("AboutTitleWiki", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Bah!. /// </summary> internal static string Beep1 { get { return ResourceManager.GetString("Beep1", resourceCulture); } } - + /// <summary> - /// Looks up a localized string similar to Bop! . + /// Looks up a localized string similar to Bop!. /// </summary> internal static string Beep2 { get { return ResourceManager.GetString("Beep2", resourceCulture); } } - + /// <summary> - /// Looks up a localized string similar to Beep! . + /// Looks up a localized string similar to Beep!. /// </summary> internal static string Beep3 { get { return ResourceManager.GetString("Beep3", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot ban users from this guild!. /// </summary> @@ -94,7 +148,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotBanMembers", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot ban this user!. /// </summary> @@ -103,7 +157,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotBanTarget", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot kick members from this guild!. /// </summary> @@ -112,7 +166,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotKickMembers", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot kick this member!. /// </summary> @@ -121,7 +175,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotKickTarget", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot manage this guild!. /// </summary> @@ -130,7 +184,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotManageGuild", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot manage messages in this guild!. /// </summary> @@ -139,7 +193,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotManageMessages", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot moderate members in this guild!. /// </summary> @@ -148,7 +202,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotModerateMembers", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot mute this member!. /// </summary> @@ -157,7 +211,7 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotMuteTarget", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot unmute this member!. /// </summary> @@ -166,25 +220,34 @@ namespace Boyfriend { return ResourceManager.GetString("BotCannotUnmuteTarget", resourceCulture); } } - + /// <summary> - /// 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}. + /// </summary> + internal static string CachedMessageCleared { + get { + return ResourceManager.GetString("CachedMessageCleared", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Deleted message by {0}:. /// </summary> internal static string CachedMessageDeleted { get { return ResourceManager.GetString("CachedMessageDeleted", resourceCulture); } } - + /// <summary> - /// 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}:. /// </summary> internal static string CachedMessageEdited { get { return ResourceManager.GetString("CachedMessageEdited", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I cannot use time-outs on other bots! Try to set a mute role in settings. /// </summary> @@ -193,7 +256,7 @@ namespace Boyfriend { return ResourceManager.GetString("CannotTimeOutBot", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Not specified. /// </summary> @@ -202,7 +265,7 @@ namespace Boyfriend { return ResourceManager.GetString("ChannelNotSpecified", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to You need to specify an integer from {0} to {1} instead of {2}!. /// </summary> @@ -211,7 +274,7 @@ namespace Boyfriend { return ResourceManager.GetString("ClearAmountInvalid", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to You specified more than {0} messages!. /// </summary> @@ -220,7 +283,7 @@ namespace Boyfriend { return ResourceManager.GetString("ClearAmountTooLarge", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to You specified less than {0} messages!. /// </summary> @@ -229,7 +292,7 @@ namespace Boyfriend { return ResourceManager.GetString("ClearAmountTooSmall", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Bans a user. /// </summary> @@ -238,7 +301,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionBan", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Deletes a specified amount of messages in this channel. /// </summary> @@ -247,7 +310,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionClear", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Shows this message. /// </summary> @@ -256,7 +319,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionHelp", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Kicks a member. /// </summary> @@ -265,7 +328,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionKick", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Mutes a member. /// </summary> @@ -274,7 +337,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionMute", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Shows (inaccurate) latency. /// </summary> @@ -283,7 +346,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionPing", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Adds a reminder. /// </summary> @@ -292,7 +355,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionRemind", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Allows you to change certain preferences for this guild. /// </summary> @@ -301,7 +364,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionSettings", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Unbans a user. /// </summary> @@ -310,7 +373,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionUnban", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Unmutes a member. /// </summary> @@ -319,7 +382,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandDescriptionUnmute", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Command help:. /// </summary> @@ -328,7 +391,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandHelp", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to I do not have permission to execute this command!. /// </summary> @@ -337,7 +400,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandNoPermissionBot", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to You do not have permission to execute this command!. /// </summary> @@ -346,7 +409,7 @@ namespace Boyfriend { return ResourceManager.GetString("CommandNoPermissionUser", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Current settings:. /// </summary> @@ -355,7 +418,7 @@ namespace Boyfriend { return ResourceManager.GetString("CurrentSettings", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to {0}, welcome to {1}. /// </summary> @@ -364,7 +427,79 @@ namespace Boyfriend { return ResourceManager.GetString("DefaultWelcomeMessage", resourceCulture); } } - + + /// <summary> + /// Looks up a localized string similar to Expires at: {0}. + /// </summary> + internal static string DescriptionActionExpiresAt { + get { + return ResourceManager.GetString("DescriptionActionExpiresAt", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Reason: {0}. + /// </summary> + internal static string DescriptionActionReason { + get { + return ResourceManager.GetString("DescriptionActionReason", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The event will start at {0} until {1} in {2}. + /// </summary> + internal static string DescriptionExternalEventCreated { + get { + return ResourceManager.GetString("DescriptionExternalEventCreated", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The event is happening at {0} until {1}. + /// </summary> + internal static string DescriptionExternalEventStarted { + get { + return ResourceManager.GetString("DescriptionExternalEventStarted", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The event will start at {0} in {1}. + /// </summary> + internal static string DescriptionLocalEventCreated { + get { + return ResourceManager.GetString("DescriptionLocalEventCreated", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The event is happening at {0}. + /// </summary> + internal static string DescriptionLocalEventStarted { + get { + return ResourceManager.GetString("DescriptionLocalEventStarted", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You asked me to remind you {0}. + /// </summary> + internal static string DescriptionReminder { + get { + return ResourceManager.GetString("DescriptionReminder", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to OK, I'll mention you on {0}. + /// </summary> + internal static string DescriptionReminderCreated { + get { + return ResourceManager.GetString("DescriptionReminderCreated", resourceCulture); + } + } + /// <summary> /// 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. /// </summary> @@ -373,25 +508,25 @@ namespace Boyfriend { return ResourceManager.GetString("DurationRequiredForTimeOuts", resourceCulture); } } - + /// <summary> - /// Looks up a localized string similar to Event {0} is cancelled!{1}. + /// Looks up a localized string similar to Event "{0}" is cancelled!. /// </summary> internal static string EventCancelled { get { return ResourceManager.GetString("EventCancelled", resourceCulture); } } - + /// <summary> - /// Looks up a localized string similar to Event {0} has completed! Duration:{1}. + /// Looks up a localized string similar to Event "{0}" has completed!. /// </summary> internal static string EventCompleted { get { return ResourceManager.GetString("EventCompleted", resourceCulture); } } - + /// <summary> /// 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}. /// </summary> @@ -400,7 +535,34 @@ namespace Boyfriend { return ResourceManager.GetString("EventCreated", resourceCulture); } } - + + /// <summary> + /// Looks up a localized string similar to {0} has created a new event:. + /// </summary> + internal static string EventCreatedTitle { + get { + return ResourceManager.GetString("EventCreatedTitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Event details. + /// </summary> + internal static string EventDetailsButton { + get { + return ResourceManager.GetString("EventDetailsButton", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The event has lasted for `{0}`. + /// </summary> + internal static string EventDuration { + get { + return ResourceManager.GetString("EventDuration", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to {0}Event {1} will start <t:{2}:R>!. /// </summary> @@ -409,16 +571,16 @@ namespace Boyfriend { return ResourceManager.GetString("EventEarlyNotification", resourceCulture); } } - + /// <summary> - /// Looks up a localized string similar to {0}Event {1} is starting at {2}!. + /// Looks up a localized string similar to Event "{0}" started. /// </summary> internal static string EventStarted { get { return ResourceManager.GetString("EventStarted", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to ever. /// </summary> @@ -427,7 +589,7 @@ namespace Boyfriend { return ResourceManager.GetString("Ever", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Kicked {0}: {1}. /// </summary> @@ -436,7 +598,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackMemberKicked", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Muted {0} for{1}: {2}. /// </summary> @@ -445,7 +607,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackMemberMuted", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Unmuted {0}: {1}. /// </summary> @@ -454,16 +616,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackMemberUnmuted", resourceCulture); } } - - /// <summary> - /// Looks up a localized string similar to Deleted {0} messages in {1}. - /// </summary> - internal static string FeedbackMessagesCleared { - get { - return ResourceManager.GetString("FeedbackMessagesCleared", resourceCulture); - } - } - + /// <summary> /// Looks up a localized string similar to Value of setting `{0}` is now set to {1}. /// </summary> @@ -472,16 +625,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackSettingsUpdated", resourceCulture); } } - - /// <summary> - /// Looks up a localized string similar to Banned {0} for{1}: {2}. - /// </summary> - internal static string FeedbackUserBanned { - get { - return ResourceManager.GetString("FeedbackUserBanned", resourceCulture); - } - } - + /// <summary> /// Looks up a localized string similar to Unbanned {0}: {1}. /// </summary> @@ -490,7 +634,7 @@ namespace Boyfriend { return ResourceManager.GetString("FeedbackUserUnbanned", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to This channel does not exist!. /// </summary> @@ -499,619 +643,16 @@ namespace Boyfriend { return ResourceManager.GetString("InvalidChannel", resourceCulture); } } - + /// <summary> - /// 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!. /// </summary> internal static string InvalidMember { get { return ResourceManager.GetString("InvalidMember", resourceCulture); } } - - /// <summary> - /// Looks up a localized string similar to This role does not exist!. - /// </summary> - internal static string InvalidRole { - get { - return ResourceManager.GetString("InvalidRole", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Invalid setting value specified!. - /// </summary> - internal static string InvalidSettingValue { - get { - return ResourceManager.GetString("InvalidSettingValue", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify a user instead of {0}!. - /// </summary> - internal static string InvalidUser { - get { - return ResourceManager.GetString("InvalidUser", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Language not supported! Supported languages:. - /// </summary> - internal static string LanguageNotSupported { - get { - return ResourceManager.GetString("LanguageNotSupported", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Member is already muted!. - /// </summary> - internal static string MemberAlreadyMuted { - get { - return ResourceManager.GetString("MemberAlreadyMuted", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Member not muted!. - /// </summary> - internal static string MemberNotMuted { - get { - return ResourceManager.GetString("MemberNotMuted", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to ms. - /// </summary> - internal static string Milliseconds { - get { - return ResourceManager.GetString("Milliseconds", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify a reason to ban this user!. - /// </summary> - internal static string MissingBanReason { - get { - return ResourceManager.GetString("MissingBanReason", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify a reason to kick this member!. - /// </summary> - internal static string MissingKickReason { - get { - return ResourceManager.GetString("MissingKickReason", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify a guild member!. - /// </summary> - internal static string MissingMember { - get { - return ResourceManager.GetString("MissingMember", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify a reason to mute this member!. - /// </summary> - internal static string MissingMuteReason { - get { - return ResourceManager.GetString("MissingMuteReason", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify an integer from {0} to {1}!. - /// </summary> - internal static string MissingNumber { - get { - return ResourceManager.GetString("MissingNumber", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify reminder text!. - /// </summary> - internal static string MissingReminderText { - get { - return ResourceManager.GetString("MissingReminderText", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify a reason to unban this user!. - /// </summary> - internal static string MissingUnbanReason { - get { - return ResourceManager.GetString("MissingUnbanReason", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify a reason for unmute this member!. - /// </summary> - internal static string MissingUnmuteReason { - get { - return ResourceManager.GetString("MissingUnmuteReason", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You need to specify a user!. - /// </summary> - internal static string MissingUser { - get { - return ResourceManager.GetString("MissingUser", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to No. - /// </summary> - internal static string No { - get { - return ResourceManager.GetString("No", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Punishment expired. - /// </summary> - internal static string PunishmentExpired { - get { - return ResourceManager.GetString("PunishmentExpired", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to {0}I'm ready!. - /// </summary> - internal static string Ready { - get { - return ResourceManager.GetString("Ready", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Not specified. - /// </summary> - internal static string RoleNotSpecified { - get { - return ResourceManager.GetString("RoleNotSpecified", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to That setting doesn't exist!. - /// </summary> - internal static string SettingDoesntExist { - get { - return ResourceManager.GetString("SettingDoesntExist", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Not specified. - /// </summary> - internal static string SettingNotDefined { - get { - return ResourceManager.GetString("SettingNotDefined", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Automatically start scheduled events. - /// </summary> - internal static string SettingsAutoStartEvents { - get { - return ResourceManager.GetString("SettingsAutoStartEvents", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Early event start notification offset. - /// </summary> - internal static string SettingsEventEarlyNotificationOffset { - get { - return ResourceManager.GetString("SettingsEventEarlyNotificationOffset", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Channel for event notifications. - /// </summary> - internal static string SettingsEventNotificationChannel { - get { - return ResourceManager.GetString("SettingsEventNotificationChannel", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Role for event creation notifications. - /// </summary> - internal static string SettingsEventNotificationRole { - get { - return ResourceManager.GetString("SettingsEventNotificationRole", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Event start notifications receivers. - /// </summary> - internal static string SettingsEventStartedReceivers { - get { - return ResourceManager.GetString("SettingsEventStartedReceivers", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to :(. - /// </summary> - internal static string SettingsFrowningFace { - get { - return ResourceManager.GetString("SettingsFrowningFace", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Language. - /// </summary> - internal static string SettingsLang { - get { - return ResourceManager.GetString("SettingsLang", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Mute role. - /// </summary> - internal static string SettingsMuteRole { - get { - return ResourceManager.GetString("SettingsMuteRole", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Nothing changed! `{0}` is already set to {1}. - /// </summary> - internal static string SettingsNothingChanged { - get { - return ResourceManager.GetString("SettingsNothingChanged", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Prefix. - /// </summary> - internal static string SettingsPrefix { - get { - return ResourceManager.GetString("SettingsPrefix", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Channel for private notifications. - /// </summary> - internal static string SettingsPrivateFeedbackChannel { - get { - return ResourceManager.GetString("SettingsPrivateFeedbackChannel", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Channel for public notifications. - /// </summary> - internal static string SettingsPublicFeedbackChannel { - get { - return ResourceManager.GetString("SettingsPublicFeedbackChannel", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Receive startup messages. - /// </summary> - internal static string SettingsReceiveStartupMessages { - get { - return ResourceManager.GetString("SettingsReceiveStartupMessages", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Remove roles on mute. - /// </summary> - internal static string SettingsRemoveRolesOnMute { - get { - return ResourceManager.GetString("SettingsRemoveRolesOnMute", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Return roles on rejoin. - /// </summary> - internal static string SettingsReturnRolesOnRejoin { - get { - return ResourceManager.GetString("SettingsReturnRolesOnRejoin", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Send welcome messages. - /// </summary> - internal static string SettingsSendWelcomeMessages { - get { - return ResourceManager.GetString("SettingsSendWelcomeMessages", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Starter role. - /// </summary> - internal static string SettingsStarterRole { - get { - return ResourceManager.GetString("SettingsStarterRole", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Welcome message. - /// </summary> - internal static string SettingsWelcomeMessage { - get { - return ResourceManager.GetString("SettingsWelcomeMessage", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot ban me!. - /// </summary> - internal static string UserCannotBanBot { - get { - return ResourceManager.GetString("UserCannotBanBot", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot ban users from this guild!. - /// </summary> - internal static string UserCannotBanMembers { - get { - return ResourceManager.GetString("UserCannotBanMembers", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot ban the owner of this guild!. - /// </summary> - internal static string UserCannotBanOwner { - get { - return ResourceManager.GetString("UserCannotBanOwner", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot ban this user!. - /// </summary> - internal static string UserCannotBanTarget { - get { - return ResourceManager.GetString("UserCannotBanTarget", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot ban yourself!. - /// </summary> - internal static string UserCannotBanThemselves { - get { - return ResourceManager.GetString("UserCannotBanThemselves", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot kick me!. - /// </summary> - internal static string UserCannotKickBot { - get { - return ResourceManager.GetString("UserCannotKickBot", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot kick members from this guild!. - /// </summary> - internal static string UserCannotKickMembers { - get { - return ResourceManager.GetString("UserCannotKickMembers", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot kick the owner of this guild!. - /// </summary> - internal static string UserCannotKickOwner { - get { - return ResourceManager.GetString("UserCannotKickOwner", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot kick this member!. - /// </summary> - internal static string UserCannotKickTarget { - get { - return ResourceManager.GetString("UserCannotKickTarget", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot kick yourself!. - /// </summary> - internal static string UserCannotKickThemselves { - get { - return ResourceManager.GetString("UserCannotKickThemselves", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot manage this guild!. - /// </summary> - internal static string UserCannotManageGuild { - get { - return ResourceManager.GetString("UserCannotManageGuild", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot manage messages in this guild!. - /// </summary> - internal static string UserCannotManageMessages { - get { - return ResourceManager.GetString("UserCannotManageMessages", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot moderate members in this guild!. - /// </summary> - internal static string UserCannotModerateMembers { - get { - return ResourceManager.GetString("UserCannotModerateMembers", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot mute me!. - /// </summary> - internal static string UserCannotMuteBot { - get { - return ResourceManager.GetString("UserCannotMuteBot", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot mute the owner of this guild!. - /// </summary> - internal static string UserCannotMuteOwner { - get { - return ResourceManager.GetString("UserCannotMuteOwner", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot mute this member!. - /// </summary> - internal static string UserCannotMuteTarget { - get { - return ResourceManager.GetString("UserCannotMuteTarget", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot mute yourself!. - /// </summary> - internal static string UserCannotMuteThemselves { - get { - return ResourceManager.GetString("UserCannotMuteThemselves", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to .... - /// </summary> - internal static string UserCannotUnmuteBot { - get { - return ResourceManager.GetString("UserCannotUnmuteBot", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You don't need to unmute the owner of this guild!. - /// </summary> - internal static string UserCannotUnmuteOwner { - get { - return ResourceManager.GetString("UserCannotUnmuteOwner", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You cannot unmute this user!. - /// </summary> - internal static string UserCannotUnmuteTarget { - get { - return ResourceManager.GetString("UserCannotUnmuteTarget", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You are muted!. - /// </summary> - internal static string UserCannotUnmuteThemselves { - get { - return ResourceManager.GetString("UserCannotUnmuteThemselves", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to This user is not banned!. - /// </summary> - internal static string UserNotBanned { - get { - return ResourceManager.GetString("UserNotBanned", resourceCulture); - } - } - - /// <summary> - /// 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. - /// </summary> - internal static string UserNotFound { - get { - return ResourceManager.GetString("UserNotFound", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Yes. - /// </summary> - internal static string Yes { - get { - return ResourceManager.GetString("Yes", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You were banned by {0} in guild `{1}` for {2}. - /// </summary> - internal static string YouWereBanned { - get { - return ResourceManager.GetString("YouWereBanned", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to You were kicked by {0} in guild `{1}` for {2}. - /// </summary> - internal static string YouWereKicked { - get { - return ResourceManager.GetString("YouWereKicked", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to OK, I'll mention you on <t:{0}:f>. - /// </summary> - internal static string FeedbackReminderAdded { - get { - return ResourceManager.GetString("FeedbackReminderAdded", resourceCulture); - } - } - + /// <summary> /// Looks up a localized string similar to You need to specify when I should send you the reminder!. /// </summary> @@ -1120,13 +661,760 @@ namespace Boyfriend { return ResourceManager.GetString("InvalidRemindIn", resourceCulture); } } - + /// <summary> - /// 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!. /// </summary> - internal static string CachedMessageCleared { + internal static string InvalidRole { get { - return ResourceManager.GetString("CachedMessageCleared", resourceCulture); + return ResourceManager.GetString("InvalidRole", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Invalid setting value specified!. + /// </summary> + internal static string InvalidSettingValue { + get { + return ResourceManager.GetString("InvalidSettingValue", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify a user instead of {0}!. + /// </summary> + internal static string InvalidUser { + get { + return ResourceManager.GetString("InvalidUser", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Issued by. + /// </summary> + internal static string IssuedBy { + get { + return ResourceManager.GetString("IssuedBy", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Language not supported!. + /// </summary> + internal static string LanguageNotSupported { + get { + return ResourceManager.GetString("LanguageNotSupported", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Member is already muted!. + /// </summary> + internal static string MemberAlreadyMuted { + get { + return ResourceManager.GetString("MemberAlreadyMuted", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Member not muted!. + /// </summary> + internal static string MemberNotMuted { + get { + return ResourceManager.GetString("MemberNotMuted", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to From {0}:. + /// </summary> + internal static string MessageFrom { + get { + return ResourceManager.GetString("MessageFrom", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Cleared {0} messages. + /// </summary> + internal static string MessagesCleared { + get { + return ResourceManager.GetString("MessagesCleared", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to ms. + /// </summary> + internal static string Milliseconds { + get { + return ResourceManager.GetString("Milliseconds", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify a reason to ban this user!. + /// </summary> + internal static string MissingBanReason { + get { + return ResourceManager.GetString("MissingBanReason", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify a reason to kick this member!. + /// </summary> + internal static string MissingKickReason { + get { + return ResourceManager.GetString("MissingKickReason", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify a guild member!. + /// </summary> + internal static string MissingMember { + get { + return ResourceManager.GetString("MissingMember", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify a reason to mute this member!. + /// </summary> + internal static string MissingMuteReason { + get { + return ResourceManager.GetString("MissingMuteReason", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify an integer from {0} to {1}!. + /// </summary> + internal static string MissingNumber { + get { + return ResourceManager.GetString("MissingNumber", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify reminder text!. + /// </summary> + internal static string MissingReminderText { + get { + return ResourceManager.GetString("MissingReminderText", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify a reason to unban this user!. + /// </summary> + internal static string MissingUnbanReason { + get { + return ResourceManager.GetString("MissingUnbanReason", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify a reason for unmute this member!. + /// </summary> + internal static string MissingUnmuteReason { + get { + return ResourceManager.GetString("MissingUnmuteReason", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You need to specify a user!. + /// </summary> + internal static string MissingUser { + get { + return ResourceManager.GetString("MissingUser", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No. + /// </summary> + internal static string No { + get { + return ResourceManager.GetString("No", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Punishment expired. + /// </summary> + internal static string PunishmentExpired { + get { + return ResourceManager.GetString("PunishmentExpired", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to I'm ready!. + /// </summary> + internal static string Ready { + get { + return ResourceManager.GetString("Ready", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Reminder for {0}. + /// </summary> + internal static string Reminder { + get { + return ResourceManager.GetString("Reminder", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Reminder for {0} created. + /// </summary> + internal static string ReminderCreated { + get { + return ResourceManager.GetString("ReminderCreated", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Not specified. + /// </summary> + internal static string RoleNotSpecified { + get { + return ResourceManager.GetString("RoleNotSpecified", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to That setting doesn't exist!. + /// </summary> + internal static string SettingDoesntExist { + get { + return ResourceManager.GetString("SettingDoesntExist", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to is now. + /// </summary> + internal static string SettingIsNow { + get { + return ResourceManager.GetString("SettingIsNow", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Setting not changed. + /// </summary> + internal static string SettingNotChanged { + get { + return ResourceManager.GetString("SettingNotChanged", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Not specified. + /// </summary> + internal static string SettingNotDefined { + get { + return ResourceManager.GetString("SettingNotDefined", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Automatically start scheduled events. + /// </summary> + internal static string SettingsAutoStartEvents { + get { + return ResourceManager.GetString("SettingsAutoStartEvents", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Default role. + /// </summary> + internal static string SettingsDefaultRole { + get { + return ResourceManager.GetString("SettingsDefaultRole", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Early event start notification offset. + /// </summary> + internal static string SettingsEventEarlyNotificationOffset { + get { + return ResourceManager.GetString("SettingsEventEarlyNotificationOffset", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Channel for event notifications. + /// </summary> + internal static string SettingsEventNotificationChannel { + get { + return ResourceManager.GetString("SettingsEventNotificationChannel", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Role for event creation notifications. + /// </summary> + internal static string SettingsEventNotificationRole { + get { + return ResourceManager.GetString("SettingsEventNotificationRole", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Event start notifications receivers. + /// </summary> + internal static string SettingsEventStartedReceivers { + get { + return ResourceManager.GetString("SettingsEventStartedReceivers", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to :(. + /// </summary> + internal static string SettingsFrowningFace { + get { + return ResourceManager.GetString("SettingsFrowningFace", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Language. + /// </summary> + internal static string SettingsLang { + get { + return ResourceManager.GetString("SettingsLang", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Boyfriend's Settings. + /// </summary> + internal static string SettingsListTitle { + get { + return ResourceManager.GetString("SettingsListTitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Mute role. + /// </summary> + internal static string SettingsMuteRole { + get { + return ResourceManager.GetString("SettingsMuteRole", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Nothing changed! `{0}` is already set to {1}. + /// </summary> + internal static string SettingsNothingChanged { + get { + return ResourceManager.GetString("SettingsNothingChanged", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Prefix. + /// </summary> + internal static string SettingsPrefix { + get { + return ResourceManager.GetString("SettingsPrefix", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Channel for private notifications. + /// </summary> + internal static string SettingsPrivateFeedbackChannel { + get { + return ResourceManager.GetString("SettingsPrivateFeedbackChannel", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Channel for public notifications. + /// </summary> + internal static string SettingsPublicFeedbackChannel { + get { + return ResourceManager.GetString("SettingsPublicFeedbackChannel", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Receive startup messages. + /// </summary> + internal static string SettingsReceiveStartupMessages { + get { + return ResourceManager.GetString("SettingsReceiveStartupMessages", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Remove roles on mute. + /// </summary> + internal static string SettingsRemoveRolesOnMute { + get { + return ResourceManager.GetString("SettingsRemoveRolesOnMute", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Return roles on rejoin. + /// </summary> + internal static string SettingsReturnRolesOnRejoin { + get { + return ResourceManager.GetString("SettingsReturnRolesOnRejoin", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Send welcome messages. + /// </summary> + internal static string SettingsSendWelcomeMessages { + get { + return ResourceManager.GetString("SettingsSendWelcomeMessages", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Setting successfuly changed. + /// </summary> + internal static string SettingSuccessfulyChanged { + get { + return ResourceManager.GetString("SettingSuccessfulyChanged", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Welcome message. + /// </summary> + internal static string SettingsWelcomeMessage { + get { + return ResourceManager.GetString("SettingsWelcomeMessage", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This user is already banned!. + /// </summary> + internal static string UserAlreadyBanned { + get { + return ResourceManager.GetString("UserAlreadyBanned", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This user is already muted!. + /// </summary> + internal static string UserAlreadyMuted { + get { + return ResourceManager.GetString("UserAlreadyMuted", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} was banned. + /// </summary> + internal static string UserBanned { + get { + return ResourceManager.GetString("UserBanned", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot ban me!. + /// </summary> + internal static string UserCannotBanBot { + get { + return ResourceManager.GetString("UserCannotBanBot", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot ban users from this guild!. + /// </summary> + internal static string UserCannotBanMembers { + get { + return ResourceManager.GetString("UserCannotBanMembers", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot ban the owner of this guild!. + /// </summary> + internal static string UserCannotBanOwner { + get { + return ResourceManager.GetString("UserCannotBanOwner", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot ban this user!. + /// </summary> + internal static string UserCannotBanTarget { + get { + return ResourceManager.GetString("UserCannotBanTarget", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot ban yourself!. + /// </summary> + internal static string UserCannotBanThemselves { + get { + return ResourceManager.GetString("UserCannotBanThemselves", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot kick me!. + /// </summary> + internal static string UserCannotKickBot { + get { + return ResourceManager.GetString("UserCannotKickBot", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot kick members from this guild!. + /// </summary> + internal static string UserCannotKickMembers { + get { + return ResourceManager.GetString("UserCannotKickMembers", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot kick the owner of this guild!. + /// </summary> + internal static string UserCannotKickOwner { + get { + return ResourceManager.GetString("UserCannotKickOwner", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot kick this member!. + /// </summary> + internal static string UserCannotKickTarget { + get { + return ResourceManager.GetString("UserCannotKickTarget", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot kick yourself!. + /// </summary> + internal static string UserCannotKickThemselves { + get { + return ResourceManager.GetString("UserCannotKickThemselves", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot manage this guild!. + /// </summary> + internal static string UserCannotManageGuild { + get { + return ResourceManager.GetString("UserCannotManageGuild", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot manage messages in this guild!. + /// </summary> + internal static string UserCannotManageMessages { + get { + return ResourceManager.GetString("UserCannotManageMessages", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot moderate members in this guild!. + /// </summary> + internal static string UserCannotModerateMembers { + get { + return ResourceManager.GetString("UserCannotModerateMembers", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot mute me!. + /// </summary> + internal static string UserCannotMuteBot { + get { + return ResourceManager.GetString("UserCannotMuteBot", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot mute the owner of this guild!. + /// </summary> + internal static string UserCannotMuteOwner { + get { + return ResourceManager.GetString("UserCannotMuteOwner", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot mute this member!. + /// </summary> + internal static string UserCannotMuteTarget { + get { + return ResourceManager.GetString("UserCannotMuteTarget", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot mute yourself!. + /// </summary> + internal static string UserCannotMuteThemselves { + get { + return ResourceManager.GetString("UserCannotMuteThemselves", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to .... + /// </summary> + internal static string UserCannotUnmuteBot { + get { + return ResourceManager.GetString("UserCannotUnmuteBot", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You don't need to unmute the owner of this guild!. + /// </summary> + internal static string UserCannotUnmuteOwner { + get { + return ResourceManager.GetString("UserCannotUnmuteOwner", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You cannot unmute this user!. + /// </summary> + internal static string UserCannotUnmuteTarget { + get { + return ResourceManager.GetString("UserCannotUnmuteTarget", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You are muted!. + /// </summary> + internal static string UserCannotUnmuteThemselves { + get { + return ResourceManager.GetString("UserCannotUnmuteThemselves", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} was kicked. + /// </summary> + internal static string UserKicked { + get { + return ResourceManager.GetString("UserKicked", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} was muted. + /// </summary> + internal static string UserMuted { + get { + return ResourceManager.GetString("UserMuted", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This user is not banned!. + /// </summary> + internal static string UserNotBanned { + get { + return ResourceManager.GetString("UserNotBanned", resourceCulture); + } + } + + /// <summary> + /// 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. + /// </summary> + internal static string UserNotFound { + get { + return ResourceManager.GetString("UserNotFound", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to I could not find this user!. + /// </summary> + internal static string UserNotFoundShort { + get { + return ResourceManager.GetString("UserNotFoundShort", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This member is not muted!. + /// </summary> + internal static string UserNotMuted { + get { + return ResourceManager.GetString("UserNotMuted", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} was unbanned. + /// </summary> + internal static string UserUnbanned { + get { + return ResourceManager.GetString("UserUnbanned", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} was unmuted. + /// </summary> + internal static string UserUnmuted { + get { + return ResourceManager.GetString("UserUnmuted", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Yes. + /// </summary> + internal static string Yes { + get { + return ResourceManager.GetString("Yes", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You were banned. + /// </summary> + internal static string YouWereBanned { + get { + return ResourceManager.GetString("YouWereBanned", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You were kicked. + /// </summary> + 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 @@ <?xml version="1.0" encoding="utf-8"?> <root> <!-- - Microsoft ResX Schema + Microsoft ResX Schema - Version 2.0 + Version 2.0 - The primary goals of this format is to allow a simple XML format - that is mostly human readable. The generation and parsing of the - various data types are done through the TypeConverter classes - associated with the data types. + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. - Example: + Example: - ... ado.net/XML headers & schema ... - <resheader name="resmimetype">text/microsoft-resx</resheader> - <resheader name="version">2.0</resheader> - <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> - <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> - <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> - <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> - <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> - <value>[base64 mime encoded serialized .NET Framework object]</value> - </data> - <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> - <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> - <comment>This is a comment</comment> - </data> + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data> - There are any number of "resheader" rows that contain simple - name/value pairs. + There are any number of "resheader" rows that contain simple + name/value pairs. - Each data row contains a name, and value. The row also contains a - type or mimetype. Type corresponds to a .NET class that support - text/value conversion through the TypeConverter architecture. - Classes that don't support this are serialized and stored with the - mimetype set. + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. - The mimetype is used for serialized objects, and tells the - ResXResourceReader how to depersist the object. This is currently not - extensible. For a given mimetype the value must be set accordingly: + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: - Note - application/x-microsoft.net.object.binary.base64 is the format - that the ResXResourceWriter will generate, however the reader can - read any of the formats listed below. + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. - mimetype: application/x-microsoft.net.object.binary.base64 - value : The object must be serialized with - : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter - : and then encoded with base64 encoding. + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. - mimetype: application/x-microsoft.net.object.soap.base64 - value : The object must be serialized with - : System.Runtime.Serialization.Formatters.Soap.SoapFormatter - : and then encoded with base64 encoding. + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. - mimetype: application/x-microsoft.net.object.bytearray.base64 - value : The object must be serialized into a byte array - : using a System.ComponentModel.TypeConverter - : and then encoded with base64 encoding. - --> - <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> - <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> - <xsd:element name="root" msdata:IsDataSet="true"> - <xsd:complexType> - <xsd:choice maxOccurs="unbounded"> - <xsd:element name="metadata"> + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" /> - </xsd:sequence> - <xsd:attribute name="name" use="required" type="xsd:string" /> - <xsd:attribute name="type" type="xsd:string" /> - <xsd:attribute name="mimetype" type="xsd:string" /> - <xsd:attribute ref="xml:space" /> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> </xsd:complexType> - </xsd:element> - <xsd:element name="assembly"> - <xsd:complexType> - <xsd:attribute name="alias" type="xsd:string" /> - <xsd:attribute name="name" type="xsd:string" /> - </xsd:complexType> - </xsd:element> - <xsd:element name="data"> - <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> - <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> - </xsd:sequence> - <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> - <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> - <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> - <xsd:attribute ref="xml:space" /> - </xsd:complexType> - </xsd:element> - <xsd:element name="resheader"> - <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> - </xsd:sequence> - <xsd:attribute name="name" type="xsd:string" use="required" /> - </xsd:complexType> - </xsd:element> - </xsd:choice> - </xsd:complexType> - </xsd:element> - </xsd:schema> - <resheader name="resmimetype"> - <value>text/microsoft-resx</value> - </resheader> - <resheader name="version"> - <value>2.0</value> - </resheader> - <resheader name="reader"> - <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> - </resheader> - <resheader name="writer"> - <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> - </resheader> - <data name="Ready" xml:space="preserve"> - <value>{0}I'm ready!</value> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value> System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> + </resheader> + <resheader name="writer"> + <value> System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> + </resheader> + <data name="Ready" xml:space="preserve"> + <value>I'm ready!</value> </data> - <data name="CachedMessageDeleted" xml:space="preserve"> - <value>Deleted message from {0} in channel {1}: {2}</value> + <data name="CachedMessageDeleted" xml:space="preserve"> + <value>Deleted message by {0}:</value> </data> - <data name="CachedMessageCleared" xml:space="preserve"> + <data name="CachedMessageCleared" xml:space="preserve"> <value>Cleared message from {0} in channel {1}: {2}</value> </data> - <data name="CachedMessageEdited" xml:space="preserve"> - <value>Edited message in channel {0}: {1} -> {2}</value> + <data name="CachedMessageEdited" xml:space="preserve"> + <value>Edited message by {0}:</value> </data> - <data name="DefaultWelcomeMessage" xml:space="preserve"> + <data name="DefaultWelcomeMessage" xml:space="preserve"> <value>{0}, welcome to {1}</value> </data> - <data name="Beep1" xml:space="preserve"> - <value>Bah! </value> + <data name="Beep1" xml:space="preserve"> + <value>Bah!</value> </data> - <data name="Beep2" xml:space="preserve"> - <value>Bop! </value> + <data name="Beep2" xml:space="preserve"> + <value>Bop!</value> </data> <data name="Beep3" xml:space="preserve"> - <value>Beep! </value> + <value>Beep!</value> </data> <data name="CommandNoPermissionBot" xml:space="preserve"> <value>I do not have permission to execute this command!</value> @@ -148,8 +136,8 @@ <value>You do not have permission to execute this command!</value> </data> <data name="YouWereBanned" xml:space="preserve"> - <value>You were banned by {0} in guild `{1}` for {2}</value> - </data> + <value>You were banned</value> + </data> <data name="PunishmentExpired" xml:space="preserve"> <value>Punishment expired</value> </data> @@ -163,8 +151,8 @@ <value>Command help:</value> </data> <data name="YouWereKicked" xml:space="preserve"> - <value>You were kicked by {0} in guild `{1}` for {2}</value> - </data> + <value>You were kicked</value> + </data> <data name="Milliseconds" xml:space="preserve"> <value>ms</value> </data> @@ -177,148 +165,148 @@ <data name="RoleNotSpecified" xml:space="preserve"> <value>Not specified</value> </data> - <data name="CurrentSettings" xml:space="preserve"> + <data name="CurrentSettings" xml:space="preserve"> <value>Current settings:</value> </data> - <data name="SettingsLang" xml:space="preserve"> + <data name="SettingsLang" xml:space="preserve"> <value>Language</value> </data> - <data name="SettingsPrefix" xml:space="preserve"> + <data name="SettingsPrefix" xml:space="preserve"> <value>Prefix</value> </data> - <data name="SettingsRemoveRolesOnMute" xml:space="preserve"> + <data name="SettingsRemoveRolesOnMute" xml:space="preserve"> <value>Remove roles on mute</value> </data> - <data name="SettingsSendWelcomeMessages" xml:space="preserve"> + <data name="SettingsSendWelcomeMessages" xml:space="preserve"> <value>Send welcome messages</value> </data> <data name="SettingsMuteRole" xml:space="preserve"> <value>Mute role</value> </data> <data name="LanguageNotSupported" xml:space="preserve"> - <value>Language not supported! Supported languages:</value> - </data> - <data name="Yes" xml:space="preserve"> + <value>Language not supported!</value> + </data> + <data name="Yes" xml:space="preserve"> <value>Yes</value> </data> - <data name="No" xml:space="preserve"> + <data name="No" xml:space="preserve"> <value>No</value> </data> - <data name="UserNotBanned" xml:space="preserve"> + <data name="UserNotBanned" xml:space="preserve"> <value>This user is not banned!</value> </data> - <data name="MemberNotMuted" xml:space="preserve"> + <data name="MemberNotMuted" xml:space="preserve"> <value>Member not muted!</value> </data> <data name="SettingsWelcomeMessage" xml:space="preserve"> <value>Welcome message</value> </data> - <data name="ClearAmountInvalid" xml:space="preserve"> + <data name="ClearAmountInvalid" xml:space="preserve"> <value>You need to specify an integer from {0} to {1} instead of {2}!</value> </data> - <data name="FeedbackUserBanned" xml:space="preserve"> - <value>Banned {0} for{1}: {2}</value> + <data name="UserBanned" xml:space="preserve"> + <value>{0} was banned</value> </data> - <data name="SettingDoesntExist" xml:space="preserve"> + <data name="SettingDoesntExist" xml:space="preserve"> <value>That setting doesn't exist!</value> </data> - <data name="SettingsReceiveStartupMessages" xml:space="preserve"> + <data name="SettingsReceiveStartupMessages" xml:space="preserve"> <value>Receive startup messages</value> </data> - <data name="InvalidSettingValue" xml:space="preserve"> + <data name="InvalidSettingValue" xml:space="preserve"> <value>Invalid setting value specified!</value> </data> - <data name="InvalidRole" xml:space="preserve"> + <data name="InvalidRole" xml:space="preserve"> <value>This role does not exist!</value> </data> - <data name="InvalidChannel" xml:space="preserve"> + <data name="InvalidChannel" xml:space="preserve"> <value>This channel does not exist!</value> </data> <data name="DurationRequiredForTimeOuts" xml:space="preserve"> <value>I cannot mute someone for more than 28 days using timeouts! Either specify a duration shorter than 28 days, or set a mute role in settings</value> </data> - <data name="CannotTimeOutBot" xml:space="preserve"> + <data name="CannotTimeOutBot" xml:space="preserve"> <value>I cannot use time-outs on other bots! Try to set a mute role in settings</value> </data> - <data name="EventCreated" xml:space="preserve"> + <data name="EventCreated" xml:space="preserve"> <value>{0} has created event {1}! It will take place in {2} and will start <t:{3}:R>! \n {4}</value> </data> - <data name="SettingsEventNotificationRole" xml:space="preserve"> + <data name="SettingsEventNotificationRole" xml:space="preserve"> <value>Role for event creation notifications</value> </data> - <data name="SettingsEventNotificationChannel" xml:space="preserve"> + <data name="SettingsEventNotificationChannel" xml:space="preserve"> <value>Channel for event notifications</value> </data> - <data name="SettingsEventStartedReceivers" xml:space="preserve"> + <data name="SettingsEventStartedReceivers" xml:space="preserve"> <value>Event start notifications receivers</value> </data> - <data name="EventStarted" xml:space="preserve"> - <value>{0}Event {1} is starting at {2}!</value> + <data name="EventStarted" xml:space="preserve"> + <value>Event "{0}" started</value> </data> - <data name="SettingsFrowningFace" xml:space="preserve"> + <data name="SettingsFrowningFace" xml:space="preserve"> <value>:(</value> </data> - <data name="EventCancelled" xml:space="preserve"> - <value>Event {0} is cancelled!{1}</value> + <data name="EventCancelled" xml:space="preserve"> + <value>Event "{0}" is cancelled!</value> </data> - <data name="EventCompleted" xml:space="preserve"> - <value>Event {0} has completed! Duration:{1}</value> + <data name="EventCompleted" xml:space="preserve"> + <value>Event "{0}" has completed!</value> </data> - <data name="Ever" xml:space="preserve"> + <data name="Ever" xml:space="preserve"> <value>ever</value> </data> - <data name="FeedbackMessagesCleared" xml:space="preserve"> - <value>Deleted {0} messages in {1}</value> + <data name="MessagesCleared" xml:space="preserve"> + <value>Cleared {0} messages</value> </data> - <data name="FeedbackMemberKicked" xml:space="preserve"> + <data name="FeedbackMemberKicked" xml:space="preserve"> <value>Kicked {0}: {1}</value> </data> - <data name="FeedbackMemberMuted" xml:space="preserve"> + <data name="FeedbackMemberMuted" xml:space="preserve"> <value>Muted {0} for{1}: {2}</value> </data> - <data name="FeedbackUserUnbanned" xml:space="preserve"> + <data name="FeedbackUserUnbanned" xml:space="preserve"> <value>Unbanned {0}: {1}</value> </data> - <data name="FeedbackMemberUnmuted" xml:space="preserve"> + <data name="FeedbackMemberUnmuted" xml:space="preserve"> <value>Unmuted {0}: {1}</value> </data> - <data name="SettingsNothingChanged" xml:space="preserve"> + <data name="SettingsNothingChanged" xml:space="preserve"> <value>Nothing changed! `{0}` is already set to {1}</value> </data> - <data name="SettingNotDefined" xml:space="preserve"> + <data name="SettingNotDefined" xml:space="preserve"> <value>Not specified</value> </data> - <data name="FeedbackSettingsUpdated" xml:space="preserve"> + <data name="FeedbackSettingsUpdated" xml:space="preserve"> <value>Value of setting `{0}` is now set to {1}</value> </data> - <data name="CommandDescriptionBan" xml:space="preserve"> + <data name="CommandDescriptionBan" xml:space="preserve"> <value>Bans a user</value> </data> - <data name="CommandDescriptionClear" xml:space="preserve"> + <data name="CommandDescriptionClear" xml:space="preserve"> <value>Deletes a specified amount of messages in this channel</value> </data> - <data name="CommandDescriptionHelp" xml:space="preserve"> + <data name="CommandDescriptionHelp" xml:space="preserve"> <value>Shows this message</value> </data> - <data name="CommandDescriptionKick" xml:space="preserve"> + <data name="CommandDescriptionKick" xml:space="preserve"> <value>Kicks a member</value> </data> - <data name="CommandDescriptionMute" xml:space="preserve"> + <data name="CommandDescriptionMute" xml:space="preserve"> <value>Mutes a member</value> </data> - <data name="CommandDescriptionPing" xml:space="preserve"> + <data name="CommandDescriptionPing" xml:space="preserve"> <value>Shows (inaccurate) latency</value> </data> - <data name="CommandDescriptionSettings" xml:space="preserve"> + <data name="CommandDescriptionSettings" xml:space="preserve"> <value>Allows you to change certain preferences for this guild</value> </data> - <data name="CommandDescriptionUnban" xml:space="preserve"> + <data name="CommandDescriptionUnban" xml:space="preserve"> <value>Unbans a user</value> </data> - <data name="CommandDescriptionUnmute" xml:space="preserve"> + <data name="CommandDescriptionUnmute" xml:space="preserve"> <value>Unmutes a member</value> </data> - <data name="MissingNumber" xml:space="preserve"> + <data name="MissingNumber" xml:space="preserve"> <value>You need to specify an integer from {0} to {1}!</value> </data> <data name="MissingUser" xml:space="preserve"> @@ -331,8 +319,8 @@ <value>You need to specify a guild member!</value> </data> <data name="InvalidMember" xml:space="preserve"> - <value>You need to specify a member of this guild!</value> - </data> + <value>You need to specify a member of this guild!</value> + </data> <data name="UserCannotBanMembers" xml:space="preserve"> <value>You cannot ban users from this guild!</value> </data> @@ -345,94 +333,94 @@ <data name="UserCannotModerateMembers" xml:space="preserve"> <value>You cannot moderate members in this guild!</value> </data> - <data name="UserCannotManageGuild" xml:space="preserve"> + <data name="UserCannotManageGuild" xml:space="preserve"> <value>You cannot manage this guild!</value> </data> - <data name="BotCannotBanMembers" xml:space="preserve"> + <data name="BotCannotBanMembers" xml:space="preserve"> <value>I cannot ban users from this guild!</value> </data> - <data name="BotCannotManageMessages" xml:space="preserve"> + <data name="BotCannotManageMessages" xml:space="preserve"> <value>I cannot manage messages in this guild!</value> </data> - <data name="BotCannotKickMembers" xml:space="preserve"> + <data name="BotCannotKickMembers" xml:space="preserve"> <value>I cannot kick members from this guild!</value> </data> - <data name="BotCannotModerateMembers" xml:space="preserve"> + <data name="BotCannotModerateMembers" xml:space="preserve"> <value>I cannot moderate members in this guild!</value> </data> - <data name="BotCannotManageGuild" xml:space="preserve"> + <data name="BotCannotManageGuild" xml:space="preserve"> <value>I cannot manage this guild!</value> </data> - <data name="MissingBanReason" xml:space="preserve"> + <data name="MissingBanReason" xml:space="preserve"> <value>You need to specify a reason to ban this user!</value> </data> - <data name="MissingKickReason" xml:space="preserve"> + <data name="MissingKickReason" xml:space="preserve"> <value>You need to specify a reason to kick this member!</value> </data> - <data name="MissingMuteReason" xml:space="preserve"> + <data name="MissingMuteReason" xml:space="preserve"> <value>You need to specify a reason to mute this member!</value> </data> - <data name="MissingUnbanReason" xml:space="preserve"> + <data name="MissingUnbanReason" xml:space="preserve"> <value>You need to specify a reason to unban this user!</value> </data> - <data name="MissingUnmuteReason" xml:space="preserve"> + <data name="MissingUnmuteReason" xml:space="preserve"> <value>You need to specify a reason for unmute this member!</value> </data> <data name="UserCannotBanOwner" xml:space="preserve"> <value>You cannot ban the owner of this guild!</value> </data> - <data name="UserCannotBanThemselves" xml:space="preserve"> + <data name="UserCannotBanThemselves" xml:space="preserve"> <value>You cannot ban yourself!</value> </data> - <data name="UserCannotBanBot" xml:space="preserve"> + <data name="UserCannotBanBot" xml:space="preserve"> <value>You cannot ban me!</value> </data> - <data name="BotCannotBanTarget" xml:space="preserve"> + <data name="BotCannotBanTarget" xml:space="preserve"> <value>I cannot ban this user!</value> </data> - <data name="UserCannotBanTarget" xml:space="preserve"> + <data name="UserCannotBanTarget" xml:space="preserve"> <value>You cannot ban this user!</value> </data> - <data name="UserCannotKickOwner" xml:space="preserve"> + <data name="UserCannotKickOwner" xml:space="preserve"> <value>You cannot kick the owner of this guild!</value> </data> - <data name="UserCannotKickThemselves" xml:space="preserve"> + <data name="UserCannotKickThemselves" xml:space="preserve"> <value>You cannot kick yourself!</value> </data> - <data name="UserCannotKickBot" xml:space="preserve"> + <data name="UserCannotKickBot" xml:space="preserve"> <value>You cannot kick me!</value> </data> - <data name="BotCannotKickTarget" xml:space="preserve"> + <data name="BotCannotKickTarget" xml:space="preserve"> <value>I cannot kick this member!</value> </data> - <data name="UserCannotKickTarget" xml:space="preserve"> + <data name="UserCannotKickTarget" xml:space="preserve"> <value>You cannot kick this member!</value> </data> - <data name="UserCannotMuteOwner" xml:space="preserve"> + <data name="UserCannotMuteOwner" xml:space="preserve"> <value>You cannot mute the owner of this guild!</value> </data> - <data name="UserCannotMuteThemselves" xml:space="preserve"> + <data name="UserCannotMuteThemselves" xml:space="preserve"> <value>You cannot mute yourself!</value> </data> - <data name="UserCannotMuteBot" xml:space="preserve"> + <data name="UserCannotMuteBot" xml:space="preserve"> <value>You cannot mute me!</value> </data> - <data name="BotCannotMuteTarget" xml:space="preserve"> + <data name="BotCannotMuteTarget" xml:space="preserve"> <value>I cannot mute this member!</value> </data> - <data name="UserCannotMuteTarget" xml:space="preserve"> + <data name="UserCannotMuteTarget" xml:space="preserve"> <value>You cannot mute this member!</value> </data> - <data name="UserCannotUnmuteOwner" xml:space="preserve"> + <data name="UserCannotUnmuteOwner" xml:space="preserve"> <value>You don't need to unmute the owner of this guild!</value> </data> - <data name="UserCannotUnmuteThemselves" xml:space="preserve"> + <data name="UserCannotUnmuteThemselves" xml:space="preserve"> <value>You are muted!</value> </data> - <data name="UserCannotUnmuteBot" xml:space="preserve"> + <data name="UserCannotUnmuteBot" xml:space="preserve"> <value>...</value> </data> - <data name="BotCannotUnmuteTarget" xml:space="preserve"> + <data name="BotCannotUnmuteTarget" xml:space="preserve"> <value>I cannot unmute this member!</value> </data> <data name="UserCannotUnmuteTarget" xml:space="preserve"> @@ -445,33 +433,129 @@ <value>Early event start notification offset</value> </data> <data name="UserNotFound" xml:space="preserve"> - <value>I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago</value> - </data> - <data name="SettingsStarterRole" xml:space="preserve"> - <value>Starter role</value> - </data> + <value>I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago</value> + </data> + <data name="SettingsDefaultRole" xml:space="preserve"> + <value>Default role</value> + </data> <data name="CommandDescriptionRemind" xml:space="preserve"> - <value>Adds a reminder</value> - </data> + <value>Adds a reminder</value> + </data> <data name="SettingsPublicFeedbackChannel" xml:space="preserve"> - <value>Channel for public notifications</value> - </data> + <value>Channel for public notifications</value> + </data> <data name="SettingsPrivateFeedbackChannel" xml:space="preserve"> - <value>Channel for private notifications</value> - </data> + <value>Channel for private notifications</value> + </data> <data name="SettingsReturnRolesOnRejoin" xml:space="preserve"> - <value>Return roles on rejoin</value> - </data> + <value>Return roles on rejoin</value> + </data> <data name="SettingsAutoStartEvents" xml:space="preserve"> - <value>Automatically start scheduled events</value> - </data> + <value>Automatically start scheduled events</value> + </data> <data name="MissingReminderText" xml:space="preserve"> - <value>You need to specify reminder text!</value> - </data> - <data name="FeedbackReminderAdded" xml:space="preserve"> - <value>OK, I'll mention you on <t:{0}:f></value> + <value>You need to specify reminder text!</value> + </data> + <data name="DescriptionReminderCreated" xml:space="preserve"> + <value>OK, I'll mention you on {0}</value> </data> <data name="InvalidRemindIn" xml:space="preserve"> - <value>You need to specify when I should send you the reminder!</value> + <value>You need to specify when I should send you the reminder!</value> + </data> + <data name="IssuedBy" xml:space="preserve"> + <value>Issued by</value> + </data> + <data name="EventCreatedTitle" xml:space="preserve"> + <value>{0} has created a new event:</value> + </data> + <data name="DescriptionLocalEventCreated" xml:space="preserve"> + <value>The event will start at {0} in {1}</value> + </data> + <data name="DescriptionExternalEventCreated" xml:space="preserve"> + <value>The event will start at {0} until {1} in {2}</value> + </data> + <data name="EventDetailsButton" xml:space="preserve"> + <value>Event details</value> + </data> + <data name="EventDuration" xml:space="preserve"> + <value>The event has lasted for `{0}`</value> + </data> + <data name="DescriptionLocalEventStarted" xml:space="preserve"> + <value>The event is happening at {0}</value> + </data> + <data name="DescriptionExternalEventStarted" xml:space="preserve"> + <value>The event is happening at {0} until {1}</value> + </data> + <data name="UserAlreadyBanned" xml:space="preserve"> + <value>This user is already banned!</value> + </data> + <data name="UserUnbanned" xml:space="preserve"> + <value>{0} was unbanned</value> + </data> + <data name="UserMuted" xml:space="preserve"> + <value>{0} was muted</value> + </data> + <data name="UserUnmuted" xml:space="preserve"> + <value>{0} was unmuted</value> + </data> + <data name="UserNotMuted" xml:space="preserve"> + <value>This member is not muted!</value> + </data> + <data name="UserNotFoundShort" xml:space="preserve"> + <value>I could not find this user!</value> + </data> + <data name="UserKicked" xml:space="preserve"> + <value>{0} was kicked</value> + </data> + <data name="DescriptionActionReason" xml:space="preserve"> + <value>Reason: {0}</value> + </data> + <data name="DescriptionActionExpiresAt" xml:space="preserve"> + <value>Expires at: {0}</value> + </data> + <data name="UserAlreadyMuted" xml:space="preserve"> + <value>This user is already muted!</value> + </data> + <data name="MessageFrom" xml:space="preserve"> + <value>From {0}:</value> + </data> + <data name="AboutTitleDevelopers" xml:space="preserve"> + <value>Developers:</value> + </data> + <data name="AboutTitleWiki" xml:space="preserve"> + <value>Boyfriend's Wiki Page:</value> + </data> + <data name="AboutBot" xml:space="preserve"> + <value>About Boyfriend</value> + </data> + <data name="AboutDeveloper@mctaylors" xml:space="preserve"> + <value>logo and embed designer, Boyfriend's Wiki creator</value> + </data> + <data name="AboutDeveloper@Octol1ttle" xml:space="preserve"> + <value>main developer</value> + </data> + <data name="AboutDeveloper@neroduckale" xml:space="preserve"> + <value>developer</value> + </data> + <data name="ReminderCreated" xml:space="preserve"> + <value>Reminder for {0} created</value> + </data> + <data name="Reminder" xml:space="preserve"> + <value>Reminder for {0}</value> + </data> + <data name="DescriptionReminder" xml:space="preserve"> + <value>You asked me to remind you {0}</value> + </data> + <data name="SettingsListTitle" xml:space="preserve"> + <value>Boyfriend's Settings</value> + </data> + <data name="SettingSuccessfulyChanged" xml:space="preserve"> + <value>Setting successfuly changed</value> + </data> + <data name="SettingNotChanged" xml:space="preserve"> + <value>Setting not changed</value> + </data> + <data name="SettingIsNow" xml:space="preserve"> + <value>is now</value> </data> </root> 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 @@ <?xml version="1.0" encoding="utf-8"?> <root> <!-- - Microsoft ResX Schema + Microsoft ResX Schema - Version 2.0 + Version 2.0 - The primary goals of this format is to allow a simple XML format - that is mostly human readable. The generation and parsing of the - various data types are done through the TypeConverter classes - associated with the data types. + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. - Example: + Example: - ... ado.net/XML headers & schema ... - <resheader name="resmimetype">text/microsoft-resx</resheader> - <resheader name="version">2.0</resheader> - <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> - <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> - <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> - <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> - <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> - <value>[base64 mime encoded serialized .NET Framework object]</value> - </data> - <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> - <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> - <comment>This is a comment</comment> - </data> + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data> - There are any number of "resheader" rows that contain simple - name/value pairs. + There are any number of "resheader" rows that contain simple + name/value pairs. - Each data row contains a name, and value. The row also contains a - type or mimetype. Type corresponds to a .NET class that support - text/value conversion through the TypeConverter architecture. - Classes that don't support this are serialized and stored with the - mimetype set. + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. - The mimetype is used for serialized objects, and tells the - ResXResourceReader how to depersist the object. This is currently not - extensible. For a given mimetype the value must be set accordingly: + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: - Note - application/x-microsoft.net.object.binary.base64 is the format - that the ResXResourceWriter will generate, however the reader can - read any of the formats listed below. + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. - mimetype: application/x-microsoft.net.object.binary.base64 - value : The object must be serialized with - : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter - : and then encoded with base64 encoding. + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. - mimetype: application/x-microsoft.net.object.soap.base64 - value : The object must be serialized with - : System.Runtime.Serialization.Formatters.Soap.SoapFormatter - : and then encoded with base64 encoding. + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. - mimetype: application/x-microsoft.net.object.bytearray.base64 - value : The object must be serialized into a byte array - : using a System.ComponentModel.TypeConverter - : and then encoded with base64 encoding. - --> - <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> - <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> - <xsd:element name="root" msdata:IsDataSet="true"> - <xsd:complexType> - <xsd:choice maxOccurs="unbounded"> - <xsd:element name="metadata"> + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" /> - </xsd:sequence> - <xsd:attribute name="name" use="required" type="xsd:string" /> - <xsd:attribute name="type" type="xsd:string" /> - <xsd:attribute name="mimetype" type="xsd:string" /> - <xsd:attribute ref="xml:space" /> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> </xsd:complexType> - </xsd:element> - <xsd:element name="assembly"> - <xsd:complexType> - <xsd:attribute name="alias" type="xsd:string" /> - <xsd:attribute name="name" type="xsd:string" /> - </xsd:complexType> - </xsd:element> - <xsd:element name="data"> - <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> - <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> - </xsd:sequence> - <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> - <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> - <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> - <xsd:attribute ref="xml:space" /> - </xsd:complexType> - </xsd:element> - <xsd:element name="resheader"> - <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> - </xsd:sequence> - <xsd:attribute name="name" type="xsd:string" use="required" /> - </xsd:complexType> - </xsd:element> - </xsd:choice> - </xsd:complexType> - </xsd:element> - </xsd:schema> - <resheader name="resmimetype"> - <value>text/microsoft-resx</value> - </resheader> - <resheader name="version"> - <value>2.0</value> - </resheader> - <resheader name="reader"> - <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> - </resheader> - <resheader name="writer"> - <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> - </resheader> - <data name="Ready" xml:space="preserve"> - <value>{0}Я запустился!</value> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> + </resheader> + <data name="Ready" xml:space="preserve"> + <value>Я запустился!</value> </data> - <data name="CachedMessageDeleted" xml:space="preserve"> - <value>Удалено сообщение от {0} в канале {1}: {2}</value> + <data name="CachedMessageDeleted" xml:space="preserve"> + <value>Сообщение {0} удалено:</value> </data> - <data name="CachedMessageCleared" xml:space="preserve"> + <data name="CachedMessageCleared" xml:space="preserve"> <value>Очищено сообщение от {0} в канале {1}: {2}</value> </data> - <data name="CachedMessageEdited" xml:space="preserve"> - <value>Отредактировано сообщение в канале {0}: {1} -> {2}</value> + <data name="CachedMessageEdited" xml:space="preserve"> + <value>Сообщение {0} отредактировано:</value> </data> - <data name="DefaultWelcomeMessage" xml:space="preserve"> + <data name="DefaultWelcomeMessage" xml:space="preserve"> <value>{0}, добро пожаловать на сервер {1}</value> </data> - <data name="Beep1" xml:space="preserve"> - <value>Бап! </value> + <data name="Beep1" xml:space="preserve"> + <value>Бап!</value> </data> - <data name="Beep2" xml:space="preserve"> - <value>Боп! </value> + <data name="Beep2" xml:space="preserve"> + <value>Боп!</value> </data> <data name="Beep3" xml:space="preserve"> - <value>Бип! </value> + <value>Бип!</value> </data> <data name="CommandNoPermissionBot" xml:space="preserve"> <value>У меня недостаточно прав для выполнения этой команды!</value> @@ -147,9 +135,6 @@ <data name="CommandNoPermissionUser" xml:space="preserve"> <value>У тебя недостаточно прав для выполнения этой команды!</value> </data> - <data name="YouWereBanned" xml:space="preserve"> - <value>Тебя забанил {0} на сервере `{1}` за {2}</value> - </data> <data name="PunishmentExpired" xml:space="preserve"> <value>Время наказания истекло</value> </data> @@ -163,8 +148,8 @@ <value>Справка по командам:</value> </data> <data name="YouWereKicked" xml:space="preserve"> - <value>Тебя кикнул {0} на сервере `{1}` за {2}</value> - </data> + <value>Вы были выгнаны</value> + </data> <data name="Milliseconds" xml:space="preserve"> <value>мс</value> </data> @@ -177,148 +162,148 @@ <data name="RoleNotSpecified" xml:space="preserve"> <value>Не указана</value> </data> - <data name="CurrentSettings" xml:space="preserve"> + <data name="CurrentSettings" xml:space="preserve"> <value>Текущие настройки:</value> </data> - <data name="SettingsLang" xml:space="preserve"> + <data name="SettingsLang" xml:space="preserve"> <value>Язык</value> </data> - <data name="SettingsPrefix" xml:space="preserve"> + <data name="SettingsPrefix" xml:space="preserve"> <value>Префикс</value> </data> - <data name="SettingsRemoveRolesOnMute" xml:space="preserve"> + <data name="SettingsRemoveRolesOnMute" xml:space="preserve"> <value>Удалять роли при муте</value> </data> - <data name="SettingsSendWelcomeMessages" xml:space="preserve"> + <data name="SettingsSendWelcomeMessages" xml:space="preserve"> <value>Отправлять приветствия</value> </data> - <data name="SettingsMuteRole" xml:space="preserve"> + <data name="SettingsMuteRole" xml:space="preserve"> <value>Роль мута</value> </data> <data name="LanguageNotSupported" xml:space="preserve"> - <value>Язык не поддерживается! Поддерживаемые языки:</value> - </data> - <data name="Yes" xml:space="preserve"> + <value>Язык не поддерживается! </value> + </data> + <data name="Yes" xml:space="preserve"> <value>Да</value> </data> - <data name="No" xml:space="preserve"> + <data name="No" xml:space="preserve"> <value>Нет</value> </data> - <data name="UserNotBanned" xml:space="preserve"> + <data name="UserNotBanned" xml:space="preserve"> <value>Этот пользователь не забанен!</value> </data> - <data name="MemberNotMuted" xml:space="preserve"> + <data name="MemberNotMuted" xml:space="preserve"> <value>Участник не заглушен!</value> </data> <data name="SettingsWelcomeMessage" xml:space="preserve"> <value>Приветствие</value> </data> - <data name="ClearAmountInvalid" xml:space="preserve"> + <data name="ClearAmountInvalid" xml:space="preserve"> <value>Надо указать целое число от {0} до {1} вместо {2}!</value> </data> - <data name="FeedbackUserBanned" xml:space="preserve"> - <value>Забанен {0} на{1}: {2}</value> + <data name="UserBanned" xml:space="preserve"> + <value>{0} был(-а) забанен(-а)</value> </data> - <data name="SettingDoesntExist" xml:space="preserve"> + <data name="SettingDoesntExist" xml:space="preserve"> <value>Такая настройка не существует!</value> </data> - <data name="SettingsReceiveStartupMessages" xml:space="preserve"> + <data name="SettingsReceiveStartupMessages" xml:space="preserve"> <value>Получать сообщения о запуске</value> </data> - <data name="InvalidSettingValue" xml:space="preserve"> + <data name="InvalidSettingValue" xml:space="preserve"> <value>Указано недействительное значение для настройки!</value> </data> - <data name="InvalidRole" xml:space="preserve"> + <data name="InvalidRole" xml:space="preserve"> <value>Эта роль не существует!</value> </data> - <data name="InvalidChannel" xml:space="preserve"> + <data name="InvalidChannel" xml:space="preserve"> <value>Этот канал не существует!</value> </data> <data name="DurationRequiredForTimeOuts" xml:space="preserve"> <value>Я не могу заглушить кого-то на более чем 28 дней, используя тайм-ауты! Или укажи продолжительность менее 28 дней, или установи роль мута в настройках</value> </data> - <data name="CannotTimeOutBot" xml:space="preserve"> + <data name="CannotTimeOutBot" xml:space="preserve"> <value>Я не могу использовать тайм-ауты на других ботах! Попробуй указать роль мута в настройках</value> </data> <data name="EventCreated" xml:space="preserve"> <value>{0} создал событие {1}! Оно пройдёт в {2} и начнётся <t:{3}:R>!{4}</value> </data> - <data name="SettingsEventNotificationRole" xml:space="preserve"> + <data name="SettingsEventNotificationRole" xml:space="preserve"> <value>Роль для уведомлений о создании событий</value> </data> - <data name="SettingsEventNotificationChannel" xml:space="preserve"> + <data name="SettingsEventNotificationChannel" xml:space="preserve"> <value>Канал для уведомлений о событиях</value> </data> - <data name="SettingsEventStartedReceivers" xml:space="preserve"> + <data name="SettingsEventStartedReceivers" xml:space="preserve"> <value>Получатели уведомлений о начале событий</value> </data> - <data name="EventStarted" xml:space="preserve"> - <value>{0}Событие {1} начинается в {2}!</value> + <data name="EventStarted" xml:space="preserve"> + <value>Событие "{0}" началось</value> </data> - <data name="SettingsFrowningFace" xml:space="preserve"> + <data name="SettingsFrowningFace" xml:space="preserve"> <value>:( </value> </data> - <data name="EventCancelled" xml:space="preserve"> - <value>Событие {0} отменено!{1}</value> + <data name="EventCancelled" xml:space="preserve"> + <value>Событие "{0}" отменено!</value> </data> - <data name="EventCompleted" xml:space="preserve"> - <value>Событие {0} завершено! Продолжительность:{1}</value> + <data name="EventCompleted" xml:space="preserve"> + <value>Событие "{0}" завершено!</value> </data> - <data name="Ever" xml:space="preserve"> + <data name="Ever" xml:space="preserve"> <value>всегда</value> </data> - <data name="FeedbackMessagesCleared" xml:space="preserve"> - <value>Удалено {0} сообщений в {1}</value> + <data name="MessagesCleared" xml:space="preserve"> + <value>Очищено {0} сообщений</value> </data> - <data name="FeedbackMemberKicked" xml:space="preserve"> + <data name="FeedbackMemberKicked" xml:space="preserve"> <value>Выгнан {0}: {1}</value> </data> - <data name="FeedbackMemberMuted" xml:space="preserve"> + <data name="FeedbackMemberMuted" xml:space="preserve"> <value>Заглушен {0} на{1}: {2}</value> </data> - <data name="FeedbackUserUnbanned" xml:space="preserve"> + <data name="FeedbackUserUnbanned" xml:space="preserve"> <value>Возвращён из бана {0}: {1}</value> </data> - <data name="FeedbackMemberUnmuted" xml:space="preserve"> + <data name="FeedbackMemberUnmuted" xml:space="preserve"> <value>Разглушен {0}: {1}</value> </data> - <data name="SettingsNothingChanged" xml:space="preserve"> + <data name="SettingsNothingChanged" xml:space="preserve"> <value>Ничего не изменилось! Значение настройки `{0}` уже {1}</value> </data> - <data name="SettingNotDefined" xml:space="preserve"> + <data name="SettingNotDefined" xml:space="preserve"> <value>Не указано</value> </data> - <data name="FeedbackSettingsUpdated" xml:space="preserve"> + <data name="FeedbackSettingsUpdated" xml:space="preserve"> <value>Значение настройки `{0}` теперь установлено на {1}</value> </data> - <data name="CommandDescriptionBan" xml:space="preserve"> + <data name="CommandDescriptionBan" xml:space="preserve"> <value>Банит пользователя</value> </data> - <data name="CommandDescriptionClear" xml:space="preserve"> + <data name="CommandDescriptionClear" xml:space="preserve"> <value>Удаляет указанное количество сообщений в этом канале</value> </data> - <data name="CommandDescriptionHelp" xml:space="preserve"> + <data name="CommandDescriptionHelp" xml:space="preserve"> <value>Показывает эту справку</value> </data> - <data name="CommandDescriptionKick" xml:space="preserve"> + <data name="CommandDescriptionKick" xml:space="preserve"> <value>Выгоняет участника</value> </data> - <data name="CommandDescriptionMute" xml:space="preserve"> + <data name="CommandDescriptionMute" xml:space="preserve"> <value>Глушит участника</value> </data> - <data name="CommandDescriptionPing" xml:space="preserve"> + <data name="CommandDescriptionPing" xml:space="preserve"> <value>Показывает (неточную) задержку</value> </data> - <data name="CommandDescriptionSettings" xml:space="preserve"> + <data name="CommandDescriptionSettings" xml:space="preserve"> <value>Позволяет менять некоторые настройки под этот сервер</value> </data> - <data name="CommandDescriptionUnban" xml:space="preserve"> + <data name="CommandDescriptionUnban" xml:space="preserve"> <value>Возвращает пользователя из бана</value> </data> - <data name="CommandDescriptionUnmute" xml:space="preserve"> + <data name="CommandDescriptionUnmute" xml:space="preserve"> <value>Разглушает участника</value> </data> - <data name="MissingNumber" xml:space="preserve"> + <data name="MissingNumber" xml:space="preserve"> <value>Надо указать целое число от {0} до {1}!</value> </data> <data name="MissingUser" xml:space="preserve"> @@ -331,8 +316,8 @@ <value>Надо указать участника сервера!</value> </data> <data name="InvalidMember" xml:space="preserve"> - <value>Надо указать участника этого сервера!</value> - </data> + <value>Надо указать участника этого сервера!</value> + </data> <data name="UserCannotBanMembers" xml:space="preserve"> <value>Ты не можешь банить пользователей на этом сервере!</value> </data> @@ -345,94 +330,94 @@ <data name="UserCannotModerateMembers" xml:space="preserve"> <value>Ты не можешь модерировать участников этого сервера!</value> </data> - <data name="UserCannotManageGuild" xml:space="preserve"> + <data name="UserCannotManageGuild" xml:space="preserve"> <value>Ты не можешь настраивать этот сервер!</value> </data> - <data name="BotCannotBanMembers" xml:space="preserve"> + <data name="BotCannotBanMembers" xml:space="preserve"> <value>Я не могу банить пользователей на этом сервере!</value> </data> - <data name="BotCannotManageMessages" xml:space="preserve"> + <data name="BotCannotManageMessages" xml:space="preserve"> <value>Я не могу управлять сообщениями этого сервера!</value> </data> - <data name="BotCannotKickMembers" xml:space="preserve"> + <data name="BotCannotKickMembers" xml:space="preserve"> <value>Я не могу выгонять участников с этого сервера!</value> </data> - <data name="BotCannotModerateMembers" xml:space="preserve"> + <data name="BotCannotModerateMembers" xml:space="preserve"> <value>Я не могу модерировать участников этого сервера!</value> </data> - <data name="BotCannotManageGuild" xml:space="preserve"> + <data name="BotCannotManageGuild" xml:space="preserve"> <value>Я не могу настраивать этот сервер!</value> </data> - <data name="MissingBanReason" xml:space="preserve"> + <data name="MissingBanReason" xml:space="preserve"> <value>Надо указать причину для бана этого участника!</value> </data> - <data name="MissingKickReason" xml:space="preserve"> + <data name="MissingKickReason" xml:space="preserve"> <value>Надо указать причину для кика этого участника!</value> </data> - <data name="MissingMuteReason" xml:space="preserve"> + <data name="MissingMuteReason" xml:space="preserve"> <value>Надо указать причину для мута этого участника!</value> </data> <data name="MissingUnbanReason" xml:space="preserve"> <value>Надо указать причину для разбана этого пользователя!</value> </data> - <data name="MissingUnmuteReason" xml:space="preserve"> + <data name="MissingUnmuteReason" xml:space="preserve"> <value>Надо указать причину для размута этого участника!</value> </data> - <data name="UserCannotBanBot" xml:space="preserve"> + <data name="UserCannotBanBot" xml:space="preserve"> <value>Ты не можешь меня забанить!</value> </data> - <data name="UserCannotBanOwner" xml:space="preserve"> + <data name="UserCannotBanOwner" xml:space="preserve"> <value>Ты не можешь забанить владельца этого сервера!</value> </data> - <data name="UserCannotBanTarget" xml:space="preserve"> + <data name="UserCannotBanTarget" xml:space="preserve"> <value>Ты не можешь забанить этого участника!</value> </data> - <data name="UserCannotBanThemselves" xml:space="preserve"> + <data name="UserCannotBanThemselves" xml:space="preserve"> <value>Ты не можешь себя забанить!</value> </data> - <data name="BotCannotBanTarget" xml:space="preserve"> + <data name="BotCannotBanTarget" xml:space="preserve"> <value>Я не могу забанить этого пользователя!</value> </data> - <data name="UserCannotKickOwner" xml:space="preserve"> + <data name="UserCannotKickOwner" xml:space="preserve"> <value>Ты не можешь выгнать владельца этого сервера!</value> </data> - <data name="UserCannotKickThemselves" xml:space="preserve"> + <data name="UserCannotKickThemselves" xml:space="preserve"> <value>Ты не можешь себя выгнать!</value> </data> - <data name="UserCannotKickBot" xml:space="preserve"> + <data name="UserCannotKickBot" xml:space="preserve"> <value>Ты не можешь меня выгнать!</value> </data> - <data name="BotCannotKickTarget" xml:space="preserve"> + <data name="BotCannotKickTarget" xml:space="preserve"> <value>Я не могу выгнать этого участника</value> </data> - <data name="UserCannotKickTarget" xml:space="preserve"> + <data name="UserCannotKickTarget" xml:space="preserve"> <value>Ты не можешь выгнать этого участника!</value> </data> - <data name="UserCannotMuteOwner" xml:space="preserve"> + <data name="UserCannotMuteOwner" xml:space="preserve"> <value>Ты не можешь заглушить владельца этого сервера!</value> </data> - <data name="UserCannotMuteThemselves" xml:space="preserve"> + <data name="UserCannotMuteThemselves" xml:space="preserve"> <value>Ты не можешь себя заглушить!</value> </data> - <data name="UserCannotMuteBot" xml:space="preserve"> + <data name="UserCannotMuteBot" xml:space="preserve"> <value>Ты не можешь заглушить меня!</value> </data> - <data name="BotCannotMuteTarget" xml:space="preserve"> + <data name="BotCannotMuteTarget" xml:space="preserve"> <value>Я не могу заглушить этого пользователя!</value> </data> - <data name="UserCannotMuteTarget" xml:space="preserve"> + <data name="UserCannotMuteTarget" xml:space="preserve"> <value>Ты не можешь заглушить этого участника!</value> </data> - <data name="UserCannotUnmuteOwner" xml:space="preserve"> + <data name="UserCannotUnmuteOwner" xml:space="preserve"> <value>Тебе не надо возвращать из мута владельца этого сервера!</value> </data> - <data name="UserCannotUnmuteThemselves" xml:space="preserve"> + <data name="UserCannotUnmuteThemselves" xml:space="preserve"> <value>Ты заглушен!</value> </data> - <data name="UserCannotUnmuteBot" xml:space="preserve"> + <data name="UserCannotUnmuteBot" xml:space="preserve"> <value>... </value> </data> - <data name="UserCannotUnmuteTarget" xml:space="preserve"> + <data name="UserCannotUnmuteTarget" xml:space="preserve"> <value>Ты не можешь вернуть из мута этого пользователя!</value> </data> <data name="BotCannotUnmuteTarget" xml:space="preserve"> @@ -445,33 +430,132 @@ <value>Офсет отправки преждевременного уведомления о начале события</value> </data> <data name="UserNotFound" xml:space="preserve"> - <value>Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад</value> - </data> - <data name="SettingsStarterRole" xml:space="preserve"> - <value>Начальная роль</value> - </data> + <value>Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад</value> + </data> + <data name="SettingsDefaultRole" xml:space="preserve"> + <value>Общая роль</value> + </data> <data name="CommandDescriptionRemind" xml:space="preserve"> - <value>Добавляет напоминание</value> - </data> + <value>Добавляет напоминание</value> + </data> <data name="SettingsPublicFeedbackChannel" xml:space="preserve"> - <value>Канал для публичных уведомлений</value> - </data> + <value>Канал для публичных уведомлений</value> + </data> <data name="SettingsPrivateFeedbackChannel" xml:space="preserve"> - <value>Канал для приватных уведомлений</value> - </data> + <value>Канал для приватных уведомлений</value> + </data> <data name="SettingsReturnRolesOnRejoin" xml:space="preserve"> - <value>Возвращать роли при перезаходе</value> + <value>Возвращать роли при перезаходе</value> + </data> + <data name="SettingsAutoStartEvents" xml:space="preserve"> + <value>Автоматически начинать события</value> + </data> + <data name="MissingReminderText" xml:space="preserve"> + <value>Тебе нужно указать текст напоминания!</value> + </data> + <data name="DescriptionReminderCreated" xml:space="preserve"> + <value>Хорошо, я упомяну тебя {0}</value> </data> - <data name="SettingsAutoStartEvents" xml:space="preserve"> - <value>Автоматически начинать события</value> + <data name="InvalidRemindIn" xml:space="preserve"> + <value>Нужно указать время, через которое придёт напоминание!</value> + </data> + <data name="IssuedBy" xml:space="preserve"> + <value>Ответственный</value> + </data> + <data name="EventCreatedTitle" xml:space="preserve"> + <value>{0} создаёт новое событие:</value> + </data> + <data name="DescriptionLocalEventCreated" xml:space="preserve"> + <value>Событие пройдёт {0} в канале {1}</value> + </data> + <data name="DescriptionExternalEventCreated" xml:space="preserve"> + <value>Событие пройдёт с {0} до {1} в {2}</value> + </data> + <data name="EventDetailsButton" xml:space="preserve"> + <value>Подробнее о событии</value> + </data> + <data name="EventDuration" xml:space="preserve"> + <value>Событие длилось `{0}`</value> + </data> + <data name="DescriptionLocalEventStarted" xml:space="preserve"> + <value>Событие происходит в {0}</value> + </data> + <data name="DescriptionExternalEventStarted" xml:space="preserve"> + <value>Событие происходит в {0} до {1}</value> + </data> + <data name="UserAlreadyBanned" xml:space="preserve"> + <value>Этот пользователь уже забанен!</value> + </data> + <data name="UserUnbanned" xml:space="preserve"> + <value>{0} был(-а) разбанен(-а)</value> + </data> + <data name="UserMuted" xml:space="preserve"> + <value>{0} был(-а) заглушен(-а)</value> + </data> + <data name="UserNotMuted" xml:space="preserve"> + <value>Этот участник не заглушен!</value> + </data> + <data name="UserUnmuted" xml:space="preserve"> + <value>{0} был(-а) разглушен(-а)</value> + </data> + <data name="UserNotFoundShort" xml:space="preserve"> + <value>Я не смог найти этого пользователя!</value> + </data> + <data name="UserKicked" xml:space="preserve"> + <value>{0} был(-а) выгнан(-а)</value> + </data> + <data name="DescriptionActionReason" xml:space="preserve"> + <value>Причина: {0}</value> + </data> + <data name="DescriptionActionExpiresAt" xml:space="preserve"> + <value>Закончится: {0}</value> + </data> + <data name="UserAlreadyMuted" xml:space="preserve"> + <value>Этот пользователь уже в муте!</value> + </data> + <data name="YouWereBanned" xml:space="preserve"> + <value>Вы были забанены</value> + </data> + <data name="MessageFrom" xml:space="preserve"> + <value>От {0}:</value> + </data> + <data name="AboutTitleDevelopers" xml:space="preserve"> + <value>Разработчики:</value> + </data> + <data name="AboutTitleWiki" xml:space="preserve"> + <value>Страница Boyfriend's Wiki:</value> + </data> + <data name="AboutBot" xml:space="preserve"> + <value>О Boyfriend</value> + </data> + <data name="AboutDeveloper@neroduckale" xml:space="preserve"> + <value>разрабочик</value> + </data> + <data name="AboutDeveloper@Octol1ttle" xml:space="preserve"> + <value>основной разработчик</value> + </data> + <data name="AboutDeveloper@mctaylors" xml:space="preserve"> + <value>дизайнер лого и эмбедов, создатель Boyfriend's Wiki</value> + </data> + <data name="ReminderCreated" xml:space="preserve"> + <value>Напоминание для {0} создано</value> </data> - <data name="MissingReminderText" xml:space="preserve"> - <value>Тебе нужно указать текст напоминания!</value> + <data name="Reminder" xml:space="preserve"> + <value>Напоминание для {0}</value> </data> - <data name="FeedbackReminderAdded" xml:space="preserve"> - <value>Хорошо, я упомяну тебя <t:{0}:f></value> + <data name="DescriptionReminder" xml:space="preserve"> + <value>Вы просили напомнить вам {0}</value> </data> - <data name="InvalidRemindIn" xml:space="preserve"> - <value>Нужно указать время, через которое придёт напоминание!</value> + <data name="SettingsListTitle" xml:space="preserve"> + <value>Настройки Boyfriend</value> + </data> + <data name="SettingSuccessfulyChanged" xml:space="preserve"> + <value>Настройка успешно изменена</value> + </data> + <data name="SettingNotChanged" xml:space="preserve"> + <value>Настройка не редактирована</value> + </data> + <data name="SettingIsNow" xml:space="preserve"> + <value>теперь имеет значение</value> </data> </root> 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 @@ <?xml version="1.0" encoding="utf-8"?> <root> <!-- - Microsoft ResX Schema + Microsoft ResX Schema - Version 2.0 + Version 2.0 - The primary goals of this format is to allow a simple XML format - that is mostly human readable. The generation and parsing of the - various data types are done through the TypeConverter classes - associated with the data types. + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. - Example: + Example: - ... ado.net/XML headers & schema ... - <resheader name="resmimetype">text/microsoft-resx</resheader> - <resheader name="version">2.0</resheader> - <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> - <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> - <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> - <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> - <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> - <value>[base64 mime encoded serialized .NET Framework object]</value> - </data> - <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> - <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> - <comment>This is a comment</comment> - </data> + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data> - There are any number of "resheader" rows that contain simple - name/value pairs. + There are any number of "resheader" rows that contain simple + name/value pairs. - Each data row contains a name, and value. The row also contains a - type or mimetype. Type corresponds to a .NET class that support - text/value conversion through the TypeConverter architecture. - Classes that don't support this are serialized and stored with the - mimetype set. + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. - The mimetype is used for serialized objects, and tells the - ResXResourceReader how to depersist the object. This is currently not - extensible. For a given mimetype the value must be set accordingly: + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: - Note - application/x-microsoft.net.object.binary.base64 is the format - that the ResXResourceWriter will generate, however the reader can - read any of the formats listed below. + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. - mimetype: application/x-microsoft.net.object.binary.base64 - value : The object must be serialized with - : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter - : and then encoded with base64 encoding. + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. - mimetype: application/x-microsoft.net.object.soap.base64 - value : The object must be serialized with - : System.Runtime.Serialization.Formatters.Soap.SoapFormatter - : and then encoded with base64 encoding. + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. - mimetype: application/x-microsoft.net.object.bytearray.base64 - value : The object must be serialized into a byte array - : using a System.ComponentModel.TypeConverter - : and then encoded with base64 encoding. - --> - <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> - <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> - <xsd:element name="root" msdata:IsDataSet="true"> - <xsd:complexType> - <xsd:choice maxOccurs="unbounded"> - <xsd:element name="metadata"> + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" /> - </xsd:sequence> - <xsd:attribute name="name" use="required" type="xsd:string" /> - <xsd:attribute name="type" type="xsd:string" /> - <xsd:attribute name="mimetype" type="xsd:string" /> - <xsd:attribute ref="xml:space" /> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> </xsd:complexType> - </xsd:element> - <xsd:element name="assembly"> - <xsd:complexType> - <xsd:attribute name="alias" type="xsd:string" /> - <xsd:attribute name="name" type="xsd:string" /> - </xsd:complexType> - </xsd:element> - <xsd:element name="data"> - <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> - <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> - </xsd:sequence> - <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> - <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> - <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> - <xsd:attribute ref="xml:space" /> - </xsd:complexType> - </xsd:element> - <xsd:element name="resheader"> - <xsd:complexType> - <xsd:sequence> - <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> - </xsd:sequence> - <xsd:attribute name="name" type="xsd:string" use="required" /> - </xsd:complexType> - </xsd:element> - </xsd:choice> - </xsd:complexType> - </xsd:element> - </xsd:schema> - <resheader name="resmimetype"> - <value>text/microsoft-resx</value> - </resheader> - <resheader name="version"> - <value>2.0</value> - </resheader> - <resheader name="reader"> - <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> - </resheader> - <resheader name="writer"> - <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> - </resheader> - <data name="Ready" xml:space="preserve"> - <value>{0}я родился!</value> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> + </resheader> + <data name="Ready" xml:space="preserve"> + <value>я родился!</value> </data> - <data name="CachedMessageDeleted" xml:space="preserve"> - <value>вырезано сообщение от {0} в канале {1}: {2}</value> + <data name="CachedMessageDeleted" xml:space="preserve"> + <value>сообщение {0} вырезано:</value> </data> - <data name="CachedMessageCleared" xml:space="preserve"> + <data name="CachedMessageCleared" xml:space="preserve"> <value>вырезано сообщение (используя `!clear`) от {0} в канале {1}: {2}</value> </data> - <data name="CachedMessageEdited" xml:space="preserve"> - <value>переделано сообщение от {0}: {1} -> {2}</value> + <data name="CachedMessageEdited" xml:space="preserve"> + <value>сообщение {0} переделано:</value> </data> - <data name="DefaultWelcomeMessage" xml:space="preserve"> + <data name="DefaultWelcomeMessage" xml:space="preserve"> <value>{0}, добро пожаловать на сервер {1}</value> </data> - <data name="Beep1" xml:space="preserve"> - <value>брах! </value> + <data name="Beep1" xml:space="preserve"> + <value>брах!</value> </data> - <data name="Beep2" xml:space="preserve"> - <value>брох! </value> + <data name="Beep2" xml:space="preserve"> + <value>брох!</value> </data> <data name="Beep3" xml:space="preserve"> - <value>брух! </value> + <value>брух!</value> </data> <data name="CommandNoPermissionBot" xml:space="preserve"> <value>у меня прав нету, сделай что нибудь.</value> @@ -148,8 +136,8 @@ <value>у тебя прав нету, твои проблемы.</value> </data> <data name="YouWereBanned" xml:space="preserve"> - <value>здарова, тебя крч забанил {0} на сервере `{1}` за {2}</value> - </data> + <value>вы были забанены</value> + </data> <data name="PunishmentExpired" xml:space="preserve"> <value>время бана закончиловсь</value> </data> @@ -163,8 +151,8 @@ <value>туториал по приколам:</value> </data> <data name="YouWereKicked" xml:space="preserve"> - <value>здарова, тебя крч кикнул {0} на сервере `{1}` за {2}</value> - </data> + <value>вы были кикнуты</value> + </data> <data name="Milliseconds" xml:space="preserve"> <value>мс</value> </data> @@ -175,150 +163,150 @@ <value>*тут ничего нет*</value> </data> <data name="RoleNotSpecified" xml:space="preserve"> - <value>нъет</value> - </data> - <data name="CurrentSettings" xml:space="preserve"> + <value>нъет</value> + </data> + <data name="CurrentSettings" xml:space="preserve"> <value>настройки:</value> </data> - <data name="SettingsLang" xml:space="preserve"> + <data name="SettingsLang" xml:space="preserve"> <value>язык</value> </data> - <data name="SettingsPrefix" xml:space="preserve"> + <data name="SettingsPrefix" xml:space="preserve"> <value>префикс</value> </data> - <data name="SettingsRemoveRolesOnMute" xml:space="preserve"> + <data name="SettingsRemoveRolesOnMute" xml:space="preserve"> <value>удалять звание при муте</value> </data> - <data name="SettingsSendWelcomeMessages" xml:space="preserve"> + <data name="SettingsSendWelcomeMessages" xml:space="preserve"> <value>разглашать о том что пришел новый шизоид</value> </data> <data name="SettingsMuteRole" xml:space="preserve"> - <value>звание замученного</value> - </data> - <data name="LanguageNotSupported" xml:space="preserve"> - <value>такого языка нету, ты шо, есть только такие:</value> + <value>звание замученного</value> </data> - <data name="Yes" xml:space="preserve"> + <data name="LanguageNotSupported" xml:space="preserve"> + <value>такого языка нету...</value> + </data> + <data name="Yes" xml:space="preserve"> <value>да</value> </data> - <data name="No" xml:space="preserve"> + <data name="No" xml:space="preserve"> <value>нъет</value> </data> - <data name="UserNotBanned" xml:space="preserve"> + <data name="UserNotBanned" xml:space="preserve"> <value>шизик не забанен</value> </data> - <data name="MemberNotMuted" xml:space="preserve"> + <data name="MemberNotMuted" xml:space="preserve"> <value>шизоид не замучен!</value> </data> <data name="SettingsWelcomeMessage" xml:space="preserve"> - <value>здравствуйте (типо настройка)</value> - </data> - <data name="ClearAmountInvalid" xml:space="preserve"> + <value>здравствуйте (типо настройка)</value> + </data> + <data name="ClearAmountInvalid" xml:space="preserve"> <value>выбери число от {0} до {1} вместо {2}!</value> </data> - <data name="FeedbackUserBanned" xml:space="preserve"> - <value>забанен {0} на{1}: {2}</value> + <data name="UserBanned" xml:space="preserve"> + <value>{0} забанен</value> </data> - <data name="SettingDoesntExist" xml:space="preserve"> + <data name="SettingDoesntExist" xml:space="preserve"> <value>такой прикол не существует</value> </data> <data name="SettingsReceiveStartupMessages" xml:space="preserve"> - <value>получать инфу о старте бота</value> - </data> - <data name="InvalidSettingValue" xml:space="preserve"> + <value>получать инфу о старте бота</value> + </data> + <data name="InvalidSettingValue" xml:space="preserve"> <value>криво настроил прикол, давай по новой</value> </data> - <data name="InvalidRole" xml:space="preserve"> + <data name="InvalidRole" xml:space="preserve"> <value>этого звания нету, ты шо</value> </data> - <data name="InvalidChannel" xml:space="preserve"> + <data name="InvalidChannel" xml:space="preserve"> <value>этого канала нету, ты шо</value> </data> <data name="DurationRequiredForTimeOuts" xml:space="preserve"> <value>ты шо, мутить больше чем на 28 дней таймаут не разрешает, вот настроишь роль мута, тогда поговорим</value> </data> - <data name="CannotTimeOutBot" xml:space="preserve"> + <data name="CannotTimeOutBot" xml:space="preserve"> <value>я не могу замутить ботов, сделай что нибудь</value> </data> <data name="EventCreated" xml:space="preserve"> - <value>{0} приготовил новый квест {1}! он пройдёт в {2} и начнётся <t:{3}:R>!{4}</value> + <value>{0} приготовил новую движуху {1}! она пройдёт в {2} и начнётся <t:{3}:R>!{4}</value> </data> - <data name="SettingsEventNotificationRole" xml:space="preserve"> - <value>роль для уведомлений о создании квеста</value> + <data name="SettingsEventNotificationRole" xml:space="preserve"> + <value>роль для уведомлений о создании движухи</value> </data> - <data name="SettingsEventNotificationChannel" xml:space="preserve"> - <value>канал для уведомлений о квестах</value> + <data name="SettingsEventNotificationChannel" xml:space="preserve"> + <value>канал для уведомлений о движухах</value> </data> - <data name="SettingsEventStartedReceivers" xml:space="preserve"> - <value>получатели уведомлений о начале квеста</value> + <data name="SettingsEventStartedReceivers" xml:space="preserve"> + <value>получатели уведомлений о начале движух</value> </data> - <data name="EventStarted" xml:space="preserve"> - <value>{0}квест {1} начинается в {2}!</value> + <data name="EventStarted" xml:space="preserve"> + <value>движуха "{0}" начинается</value> </data> - <data name="SettingsFrowningFace" xml:space="preserve"> - <value>оъмъомоъемъъео(((( </value> + <data name="SettingsFrowningFace" xml:space="preserve"> + <value>оъмъомоъемъъео((((</value> </data> - <data name="EventCancelled" xml:space="preserve"> - <value>квест {0} отменен!{1}</value> + <data name="EventCancelled" xml:space="preserve"> + <value>движуха "{0}" отменен!</value> </data> - <data name="EventCompleted" xml:space="preserve"> - <value>квест {0} завершен! все это длилось{1}</value> + <data name="EventCompleted" xml:space="preserve"> + <value>движуха "{0}" завершен!</value> </data> - <data name="Ever" xml:space="preserve"> + <data name="Ever" xml:space="preserve"> <value>всегда</value> </data> - <data name="FeedbackMessagesCleared" xml:space="preserve"> - <value>удалено {0} сообщений в {1}</value> + <data name="MessagesCleared" xml:space="preserve"> + <value>вырезано {0} забавных сообщений</value> </data> - <data name="FeedbackMemberKicked" xml:space="preserve"> + <data name="FeedbackMemberKicked" xml:space="preserve"> <value>выгнан {0}: {1}</value> </data> - <data name="FeedbackMemberMuted" xml:space="preserve"> + <data name="FeedbackMemberMuted" xml:space="preserve"> <value>замучен {0} на{1}: {2}</value> </data> - <data name="FeedbackUserUnbanned" xml:space="preserve"> + <data name="FeedbackUserUnbanned" xml:space="preserve"> <value>раззабанен {0}: {1}</value> </data> - <data name="FeedbackMemberUnmuted" xml:space="preserve"> + <data name="FeedbackMemberUnmuted" xml:space="preserve"> <value>раззамучен {0}: {1}</value> </data> - <data name="SettingsNothingChanged" xml:space="preserve"> + <data name="SettingsNothingChanged" xml:space="preserve"> <value>ты все сломал! значение прикола `{0}` и так {1}</value> </data> <data name="SettingNotDefined" xml:space="preserve"> - <value>нъет</value> - </data> - <data name="FeedbackSettingsUpdated" xml:space="preserve"> + <value>нъет</value> + </data> + <data name="FeedbackSettingsUpdated" xml:space="preserve"> <value>прикол для `{0}` теперь установлен на {1}</value> </data> - <data name="CommandDescriptionBan" xml:space="preserve"> + <data name="CommandDescriptionBan" xml:space="preserve"> <value>возводит великий банхаммер над шизоидом</value> </data> - <data name="CommandDescriptionClear" xml:space="preserve"> + <data name="CommandDescriptionClear" xml:space="preserve"> <value>удаляет сообщения. сколько хош, столько и удалит</value> </data> - <data name="CommandDescriptionHelp" xml:space="preserve"> + <data name="CommandDescriptionHelp" xml:space="preserve"> <value>показывает то, что ты сейчас видишь прямо сейчас</value> </data> - <data name="CommandDescriptionKick" xml:space="preserve"> + <data name="CommandDescriptionKick" xml:space="preserve"> <value>выпинывает шизоида</value> </data> - <data name="CommandDescriptionMute" xml:space="preserve"> + <data name="CommandDescriptionMute" xml:space="preserve"> <value>мутит шизоида</value> </data> - <data name="CommandDescriptionPing" xml:space="preserve"> + <data name="CommandDescriptionPing" xml:space="preserve"> <value>показывает пинг (сверхмегаточный (нет))</value> </data> - <data name="CommandDescriptionSettings" xml:space="preserve"> + <data name="CommandDescriptionSettings" xml:space="preserve"> <value>настройки бота под этот сервер</value> </data> - <data name="CommandDescriptionUnban" xml:space="preserve"> + <data name="CommandDescriptionUnban" xml:space="preserve"> <value>отводит великий банхаммер от шизоида</value> </data> - <data name="CommandDescriptionUnmute" xml:space="preserve"> + <data name="CommandDescriptionUnmute" xml:space="preserve"> <value>раззамучивает шизоида</value> </data> - <data name="MissingNumber" xml:space="preserve"> + <data name="MissingNumber" xml:space="preserve"> <value>укажи целое число от {0} до {1}</value> </data> <data name="MissingUser" xml:space="preserve"> @@ -331,8 +319,8 @@ <value>укажи самого шизика</value> </data> <data name="InvalidMember" xml:space="preserve"> - <value>укажи шизоида сервера!</value> - </data> + <value>укажи шизоида сервера!</value> + </data> <data name="UserCannotBanMembers" xml:space="preserve"> <value>бан</value> </data> @@ -345,133 +333,229 @@ <data name="UserCannotModerateMembers" xml:space="preserve"> <value>тебе нельзя управлять шизоидами</value> </data> - <data name="UserCannotManageGuild" xml:space="preserve"> + <data name="UserCannotManageGuild" xml:space="preserve"> <value>тебе нельзя редактировать дурку</value> </data> - <data name="BotCannotBanMembers" xml:space="preserve"> + <data name="BotCannotBanMembers" xml:space="preserve"> <value>я не могу ваще никого банить чел.</value> </data> - <data name="BotCannotManageMessages" xml:space="preserve"> + <data name="BotCannotManageMessages" xml:space="preserve"> <value>я не могу исправлять орфографический кринж участников, сделай что нибудь.</value> </data> - <data name="BotCannotKickMembers" xml:space="preserve"> + <data name="BotCannotKickMembers" xml:space="preserve"> <value>я не могу ваще никого кикать чел.</value> </data> - <data name="BotCannotModerateMembers" xml:space="preserve"> + <data name="BotCannotModerateMembers" xml:space="preserve"> <value>я не могу контроллировать за всеми ними, сделай что нибудь.</value> </data> - <data name="BotCannotManageGuild" xml:space="preserve"> + <data name="BotCannotManageGuild" xml:space="preserve"> <value>я не могу этому серверу хоть че либо нибудь изменить, сделай что нибудь.</value> </data> - <data name="MissingBanReason" xml:space="preserve"> + <data name="MissingBanReason" xml:space="preserve"> <value>укажи зачем банить шизика</value> </data> - <data name="MissingKickReason" xml:space="preserve"> + <data name="MissingKickReason" xml:space="preserve"> <value>укажи зачем кикать шизика</value> </data> - <data name="MissingMuteReason" xml:space="preserve"> + <data name="MissingMuteReason" xml:space="preserve"> <value>укажи зачем мутить шизика</value> </data> <data name="MissingUnbanReason" xml:space="preserve"> <value>укажи зачем раззабанивать шизика</value> </data> - <data name="MissingUnmuteReason" xml:space="preserve"> + <data name="MissingUnmuteReason" xml:space="preserve"> <value>укажи зачам размучивать шизика</value> </data> - <data name="UserCannotBanBot" xml:space="preserve"> + <data name="UserCannotBanBot" xml:space="preserve"> <value>ээбля френдли фаер огонь по своим</value> </data> - <data name="UserCannotBanOwner" xml:space="preserve"> + <data name="UserCannotBanOwner" xml:space="preserve"> <value>бан админу нельзя</value> </data> - <data name="UserCannotBanTarget" xml:space="preserve"> + <data name="UserCannotBanTarget" xml:space="preserve"> <value>бан этому шизику нельзя</value> </data> - <data name="UserCannotBanThemselves" xml:space="preserve"> + <data name="UserCannotBanThemselves" xml:space="preserve"> <value>самобан нельзя</value> </data> - <data name="BotCannotBanTarget" xml:space="preserve"> + <data name="BotCannotBanTarget" xml:space="preserve"> <value>я не могу его забанить...</value> </data> - <data name="UserCannotKickOwner" xml:space="preserve"> + <data name="UserCannotKickOwner" xml:space="preserve"> <value>кик админу нельзя</value> </data> - <data name="UserCannotKickThemselves" xml:space="preserve"> + <data name="UserCannotKickThemselves" xml:space="preserve"> <value>самокик нельзя</value> </data> - <data name="UserCannotKickBot" xml:space="preserve"> + <data name="UserCannotKickBot" xml:space="preserve"> <value>ээбля френдли фаер огонь по своим</value> </data> - <data name="BotCannotKickTarget" xml:space="preserve"> + <data name="BotCannotKickTarget" xml:space="preserve"> <value>я не могу его кикнуть...</value> </data> - <data name="UserCannotKickTarget" xml:space="preserve"> + <data name="UserCannotKickTarget" xml:space="preserve"> <value>кик этому шизику нельзя</value> </data> - <data name="UserCannotMuteOwner" xml:space="preserve"> + <data name="UserCannotMuteOwner" xml:space="preserve"> <value>мут админу нельзя</value> </data> - <data name="UserCannotMuteThemselves" xml:space="preserve"> + <data name="UserCannotMuteThemselves" xml:space="preserve"> <value>самомут нельзя</value> </data> - <data name="UserCannotMuteBot" xml:space="preserve"> + <data name="UserCannotMuteBot" xml:space="preserve"> <value>ээбля френдли фаер огонь по своим</value> </data> - <data name="BotCannotMuteTarget" xml:space="preserve"> + <data name="BotCannotMuteTarget" xml:space="preserve"> <value>я не могу его замутить...</value> </data> - <data name="UserCannotMuteTarget" xml:space="preserve"> + <data name="UserCannotMuteTarget" xml:space="preserve"> <value>мут этому шизику нельзя</value> </data> <data name="UserCannotUnmuteOwner" xml:space="preserve"> - <value>сильно</value> - </data> - <data name="UserCannotUnmuteThemselves" xml:space="preserve"> + <value>сильно</value> + </data> + <data name="UserCannotUnmuteThemselves" xml:space="preserve"> <value>ты замучен.</value> </data> - <data name="UserCannotUnmuteBot" xml:space="preserve"> + <data name="UserCannotUnmuteBot" xml:space="preserve"> <value>... </value> </data> - <data name="UserCannotUnmuteTarget" xml:space="preserve"> + <data name="UserCannotUnmuteTarget" xml:space="preserve"> <value>тебе нельзя раззамучивать</value> </data> <data name="BotCannotUnmuteTarget" xml:space="preserve"> <value>я не могу его раззамутить...</value> </data> <data name="EventEarlyNotification" xml:space="preserve"> - <value>{0}квест {1} начнется <t:{2}:R>!</value> + <value>{0}движуха {1} начнется <t:{2}:R>!</value> </data> <data name="SettingsEventEarlyNotificationOffset" xml:space="preserve"> - <value>заранее пнуть в минутах до начала квеста</value> + <value>заранее пнуть в минутах до начала движухи</value> </data> <data name="UserNotFound" xml:space="preserve"> - <value>у нас такого шизоида нету, проверь, валиден ли ID уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад)</value> - </data> - <data name="SettingsStarterRole" xml:space="preserve"> - <value>базовое звание</value> - </data> + <value>у нас такого шизоида нету, проверь, валиден ли ID уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад)</value> + </data> + <data name="SettingsDefaultRole" xml:space="preserve"> + <value>дефолтное звание</value> + </data> <data name="CommandDescriptionRemind" xml:space="preserve"> - <value>крафтит напоминалку</value> - </data> + <value>крафтит напоминалку</value> + </data> <data name="SettingsPrivateFeedbackChannel" xml:space="preserve"> - <value>канал для секретных уведомлений</value> - </data> + <value>канал для секретных уведомлений</value> + </data> <data name="SettingsPublicFeedbackChannel" xml:space="preserve"> - <value>канал для не секретных уведомлений</value> - </data> + <value>канал для не секретных уведомлений</value> + </data> <data name="SettingsReturnRolesOnRejoin" xml:space="preserve"> - <value>вернуть звания при переподключении в дурку</value> + <value>вернуть звания при переподключении в дурку</value> + </data> + <data name="SettingsAutoStartEvents" xml:space="preserve"> + <value>автоматом стартить движухи</value> + </data> + <data name="MissingReminderText" xml:space="preserve"> + <value>для крафта напоминалки нужен текст</value> + </data> + <data name="DescriptionReminderCreated" xml:space="preserve"> + <value>вас понял, упоминание будет {0}</value> </data> - <data name="SettingsAutoStartEvents" xml:space="preserve"> - <value>автоматом стартить квесты</value> + <data name="InvalidRemindIn" xml:space="preserve"> + <value>шизоид у меня на часах такого нету</value> + </data> + <data name="IssuedBy" xml:space="preserve"> + <value>ответственный</value> + </data> + <data name="EventCreatedTitle" xml:space="preserve"> + <value>{0} создает новое событие:</value> + </data> + <data name="DescriptionLocalEventCreated" xml:space="preserve"> + <value>движуха произойдет {0} в канале {1}</value> + </data> + <data name="DescriptionExternalEventCreated" xml:space="preserve"> + <value>движуха будет происходить с {0} до {1} в {2}</value> + </data> + <data name="EventDetailsButton" xml:space="preserve"> + <value>побольше о движухе</value> + </data> + <data name="EventDuration" xml:space="preserve"> + <value>все это длилось `{0}`</value> + </data> + <data name="DescriptionLocalEventStarted" xml:space="preserve"> + <value>движуха происходит в {0}</value> + </data> + <data name="DescriptionExternalEventStarted" xml:space="preserve"> + <value>движуха происходит в {0} до {1}</value> + </data> + <data name="UserAlreadyBanned" xml:space="preserve"> + <value>этот шизоид уже лежит в бане</value> + </data> + <data name="UserUnbanned" xml:space="preserve"> + <value>{0} раззабанен</value> + </data> + <data name="UserMuted" xml:space="preserve"> + <value>{0} в муте</value> + </data> + <data name="UserUnmuted" xml:space="preserve"> + <value>{0} в размуте</value> + </data> + <data name="UserNotMuted" xml:space="preserve"> + <value>этого шизоида никто не мутил.</value> + </data> + <data name="UserNotFoundShort" xml:space="preserve"> + <value>у нас такого шизоида нету...</value> + </data> + <data name="UserKicked" xml:space="preserve"> + <value>{0} вышел с посторонней помощью</value> + </data> + <data name="DescriptionActionReason" xml:space="preserve"> + <value>причина: {0}</value> + </data> + <data name="DescriptionActionExpiresAt" xml:space="preserve"> + <value>до: {0}</value> + </data> + <data name="UserAlreadyMuted" xml:space="preserve"> + <value>этот шизоид УЖЕ замучился</value> + </data> + <data name="MessageFrom" xml:space="preserve"> + <value>от {0}</value> + </data> + <data name="AboutTitleDevelopers" xml:space="preserve"> + <value>девелоперы:</value> + </data> + <data name="AboutTitleWiki" xml:space="preserve"> + <value>страничка Boyfriend's Wiki:</value> + </data> + <data name="AboutBot" xml:space="preserve"> + <value>немного о Boyfriend</value> + </data> + <data name="AboutDeveloper@mctaylors" xml:space="preserve"> + <value>скучный лого/эмбед дизайнер создавший Boyfriend's Wiki</value> + </data> + <data name="AboutDeveloper@neroduckale" xml:space="preserve"> + <value>ВАЖНЫЙ соучастник кодинг-стримов @Octol1ttle</value> + </data> + <data name="AboutDeveloper@Octol1ttle" xml:space="preserve"> + <value>САМЫЙ ВАЖНЫЙ чел написавший кода больше всех (99.99%)</value> + </data> + <data name="ReminderCreated" xml:space="preserve"> + <value>напоминалка для {0} скрафченА</value> </data> - <data name="MissingReminderText" xml:space="preserve"> - <value>для крафта напоминалки нужен текст</value> + <data name="Reminder" xml:space="preserve"> + <value>напоминалка для {0}</value> </data> - <data name="FeedbackReminderAdded" xml:space="preserve"> - <value>вас понял, упоминание будет <t:{0}:f></value> + <data name="DescriptionReminder" xml:space="preserve"> + <value>ты хотел чтоб я напомнил тебе {0}</value> </data> - <data name="InvalidRemindIn" xml:space="preserve"> - <value>шизоид у меня на часах такого нету</value> + <data name="SettingsListTitle" xml:space="preserve"> + <value>приколы Boyfriend</value> + </data> + <data name="SettingSuccessfulyChanged" xml:space="preserve"> + <value>прикол редактирован</value> + </data> + <data name="SettingNotChanged" xml:space="preserve"> + <value>прикол сдох</value> + </data> + <data name="SettingIsNow" xml:space="preserve"> + <value>стало</value> </data> </root> 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; + +/// <summary> +/// Handles saving, loading, initializing and providing <see cref="GuildData" />. +/// </summary> +public class GuildDataService : IHostedService { + private readonly ConcurrentDictionary<Snowflake, GuildData> _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<Task>(); + 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<GuildData> GetData(Snowflake guildId, CancellationToken ct = default) { + return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct); + } + + private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default) { + var 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<GuildConfiguration>( + configurationStream, cancellationToken: ct); + + await using var eventsStream = File.OpenRead(scheduledEventsPath); + var events + = JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>( + eventsStream, cancellationToken: ct); + + var memberData = new Dictionary<ulong, MemberData>(); + foreach (var dataPath in Directory.GetFiles(memberDataPath)) { + await using var dataStream = File.OpenRead(dataPath); + var data = await JsonSerializer.DeserializeAsync<MemberData>(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<ulong, ScheduledEventData>(), scheduledEventsPath, + memberData, memberDataPath); + while (!_datas.ContainsKey(guildId)) _datas.TryAdd(guildId, finalData); + return finalData; + } + + public async Task<GuildConfiguration> GetConfiguration(Snowflake guildId, CancellationToken ct = default) { + return (await GetData(guildId, ct)).Configuration; + } + + public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) { + return (await GetData(guildId, ct)).GetMemberData(userId); + } + + public ICollection<Snowflake> 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; + +/// <summary> +/// Handles executing guild updates (also called "ticks") once per second. +/// </summary> +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<Activity> _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<GuildUpdateService> _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<GuildUpdateService> logger, + IDiscordRestUserAPI userApi, UtilityService utility) { + _channelApi = channelApi; + _client = client; + _dataService = dataService; + _eventApi = eventApi; + _guildApi = guildApi; + _logger = logger; + _userApi = userApi; + _utility = utility; + } + + /// <summary> + /// 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 <see cref="SongList"/>. + /// </summary> + /// <remarks>If update tasks take longer than 1 second, the next timer tick will be skipped.</remarks> + /// <param name="ct">The cancellation token for this operation.</param> + protected override async Task ExecuteAsync(CancellationToken ct) { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + var tasks = new List<Task>(); + + while (await timer.WaitForNextTickAsync(ct)) { + var guildIds = _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(); + } + } + + /// <summary> + /// Runs an update ("tick") for a guild with the provided <paramref name="guildId" />. + /// </summary> + /// <remarks> + /// This method does the following: + /// <list type="bullet"> + /// <item>Automatically unbans users once their ban period has expired.</item> + /// <item>Automatically grants members the guild's <see cref="GuildConfiguration.DefaultRole"/> if one is set.</item> + /// <item>Sends reminders about an upcoming scheduled event.</item> + /// <item>Automatically starts scheduled events if <see cref="GuildConfiguration.AutoStartEvents"/> is enabled.</item> + /// <item>Sends scheduled event start notifications.</item> + /// <item>Sends scheduled event completion notifications.</item> + /// <item>Sends reminders to members.</item> + /// </list> + /// This is done here and not in a <see cref="IResponder{TGatewayEvent}" /> for the following reasons: + /// <list type="bullet"> + /// <item> + /// Downtime would affect the reliability of notifications and automatic unbans if this logic were to be in a + /// <see cref="IResponder{TGatewayEvent}" />. + /// </item> + /// <item>The Discord API doesn't provide necessary information about scheduled event updates.</item> + /// </list> + /// </remarks> + /// <param name="guildId">The ID of the guild to update.</param> + /// <param name="ct">The cancellation token for this operation.</param> + 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); + } + } + + /// <summary> + /// Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventNotificationRole" /> if one is + /// set, + /// when a scheduled event is created + /// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set. + /// </summary> + /// <param name="scheduledEvent">The scheduled event that has just been created.</param> + /// <param name="config">The configuration of the guild containing the scheduled event.</param> + /// <param name="ct">The cancellation token for this operation.</param> + /// <returns>A notification sending result which may or may not have succeeded.</returns> + private async Task<Result> SendScheduledEventCreatedMessage( + IGuildScheduledEvent scheduledEvent, 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); + } + + /// <summary> + /// Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventStartedReceivers" />s, + /// when a scheduled event is about to start, has started or completed + /// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set. + /// </summary> + /// <param name="scheduledEvent">The scheduled event that is about to start, has started or completed.</param> + /// <param name="data">The data for the guild containing the scheduled event.</param> + /// <param name="early">Controls whether or not a reminder for the scheduled event should be sent instead of the event started/completed notification</param> + /// <param name="ct">The cancellation token for this operation</param> + /// <returns>A reminder/notification sending result which may or may not have succeeded.</returns> + private async Task<Result> 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<string>), 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; + +/// <summary> +/// Provides utility methods that cannot be transformed to extension methods because they require usage +/// of some Discord APIs. +/// </summary> +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; + } + + /// <summary> + /// Checks whether or not a member can interact with another member + /// </summary> + /// <param name="guildId">The ID of the guild in which an operation is being performed.</param> + /// <param name="interacterId">The executor of the operation.</param> + /// <param name="targetId">The target of the operation.</param> + /// <param name="action">The operation.</param> + /// <param name="ct">The cancellation token for this operation.</param> + /// <returns> + /// <list type="bullet"> + /// <item>A result which has succeeded with a null string if the member can interact with the target.</item> + /// <item> + /// A result which has succeeded with a non-null string containing the error message if the member cannot + /// interact with the target. + /// </item> + /// <item>A result which has failed if an error occurred during the execution of this method.</item> + /// </list> + /// </returns> + public async Task<Result<string?>> CheckInteractionsAsync( + Snowflake guildId, Snowflake interacterId, Snowflake targetId, string action, CancellationToken ct = default) { + if (interacterId == targetId) + return Result<string?>.FromSuccess($"UserCannot{action}Themselves".Localized()); + + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) + return Result<string?>.FromError(currentUserResult); + if (currentUser.ID == targetId) + return Result<string?>.FromSuccess($"UserCannot{action}Bot".Localized()); + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct); + if (!guildResult.IsDefined(out var guild)) + return Result<string?>.FromError(guildResult); + if (targetId == guild.OwnerID) return Result<string?>.FromSuccess($"UserCannot{action}Owner".Localized()); + + var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct); + if (!targetMemberResult.IsDefined(out var targetMember)) + return Result<string?>.FromSuccess(null); + + var currentMemberResult = await _guildApi.GetGuildMemberAsync(guildId, currentUser.ID, ct); + if (!currentMemberResult.IsDefined(out var currentMember)) + return Result<string?>.FromError(currentMemberResult); + + var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct); + if (!rolesResult.IsDefined(out var roles)) + return Result<string?>.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<string?>.FromSuccess($"BotCannot{action}Target".Localized()); + + if (interacterId == guild.OwnerID) + return Result<string?>.FromSuccess(null); + + var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId, ct); + if (!interacterResult.IsDefined(out var interacter)) + return Result<string?>.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<string?>.FromSuccess($"UserCannot{action}Target".Localized()); + + return Result<string?>.FromSuccess(null); + } + + /// <summary> + /// Gets the string mentioning all <see cref="GuildConfiguration.NotificationReceiver" />s related to a scheduled + /// event. + /// </summary> + /// <remarks> + /// If the guild configuration enables <see cref="GuildConfiguration.NotificationReceiver.Role" />, then the + /// <see cref="GuildConfiguration.EventNotificationRole" /> will also be mentioned. + /// </remarks> + /// <param name="scheduledEvent"> + /// The scheduled event whose subscribers will be mentioned if the guild configuration enables + /// <see cref="GuildConfiguration.NotificationReceiver.Interested" />. + /// </param> + /// <param name="config">The configuration of the guild containing the scheduled event</param> + /// <param name="ct">The cancellation token for this operation.</param> + /// <returns>A result containing the string which may or may not have succeeded.</returns> + public async Task<Result<string>> GetEventNotificationMentions( + IGuildScheduledEvent scheduledEvent, 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<string>.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<string, CultureInfo> CultureInfoCache = new() { - { "ru", new CultureInfo("ru-RU") }, - { "en", new CultureInfo("en-US") }, - { "mctaylors-ru", new CultureInfo("tt-RU") } - }; - - private static readonly Dictionary<string, string> 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<bool> 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 @@ +<picture> + + <source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png"> + + <source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/95250141/206895340-3415d97d-91fd-4fb6-8c17-4e1bf340e1df.png"> + + <img alt="Boyfriend Logo" src="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png"> + +</picture> + + + + + + +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](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.