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>
-
-![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend)
-![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master)
-![GitHub last commit](https://img.shields.io/github/last-commit/TeamOctolings/Boyfriend)
-
-Beep! I'm a general-purpose bot for moderation written by [@Octol1ttle](https://github.com/Octol1ttle) in C# and Discord.Net
-
-# Features
-* Banning, muting, kicking, etc.
-* Reminding you about something if you wish
-* Reminding everyone about that new event you made
-* Log everything from joining the server to deleting messages
-
-*...and more!*
-
-# Getting Started
-
-You can read our [wiki](https://github.com/TeamOctolings/Boyfriend/wiki) in order to assemble your Boyfriend™ and moderate the server.
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index 36ae7a7..0000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: "CodeQL"
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
-on:
-  push:
-    branches: [ "master" ]
-  pull_request:
-    branches: [ "master" ]
-  schedule:
-    - cron: '45 7 * * 2'
-
-jobs:
-  analyze:
-    name: Analyze code
-    runs-on: ubuntu-latest
-    permissions:
-      actions: read
-      contents: read
-      security-events: write
-
-    strategy:
-      fail-fast: false
-      matrix:
-        language: [ 'csharp' ]
-
-    steps:
-    - name: Checkout repository
-      uses: actions/checkout@v3
-
-    - name: Initialize CodeQL
-      uses: github/codeql-action/init@v2
-      with:
-        languages: ${{ matrix.language }}
-        queries: +security-extended,security-and-quality
-
-    - name: Build solution
-      uses: github/codeql-action/autobuild@v2
-
-    - name: Perform CodeQL analysis
-      uses: github/codeql-action/analyze@v2
-      with:
-        category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/resharper.yml b/.github/workflows/resharper.yml
index a9cb03c..82b2562 100644
--- a/.github/workflows/resharper.yml
+++ b/.github/workflows/resharper.yml
@@ -18,9 +18,6 @@ jobs:
       contents: read
       security-events: write
 
-    strategy:
-      fail-fast: false
-
     steps:
     - name: Checkout repository
       uses: actions/checkout@v3
@@ -29,8 +26,8 @@ jobs:
       run: dotnet restore
 
     - name: ReSharper CLI InspectCode
-      uses: muno92/resharper_inspectcode@1.6.13
+      uses: muno92/resharper_inspectcode@1.7.1
       with:
         solutionPath: ./Boyfriend.sln
-        ignoreIssueType: InvertIf
+        ignoreIssueType: InvertIf, ConvertIfStatementToReturnStatement, ConvertIfStatementToSwitchStatement
         solutionWideAnalysis: true
diff --git a/.gitignore b/.gitignore
index 3816d9a..4529511 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
 .idea/
 *.user
-token.txt
 bin/
 obj/
 /packages/
diff --git a/Boyfriend.cs b/Boyfriend.cs
index aaad927..6af4326 100644
--- a/Boyfriend.cs
+++ b/Boyfriend.cs
@@ -1,180 +1,96 @@
-using System.Text;
-using System.Timers;
-using Boyfriend.Data;
-using Discord;
-using Discord.Rest;
-using Discord.WebSocket;
-using Timer = System.Timers.Timer;
+using Boyfriend.Commands;
+using Boyfriend.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Remora.Commands.Extensions;
+using Remora.Discord.API.Abstractions.Gateway.Commands;
+using Remora.Discord.API.Abstractions.Objects;
+using Remora.Discord.API.Objects;
+using Remora.Discord.Caching.Extensions;
+using Remora.Discord.Caching.Services;
+using Remora.Discord.Commands.Extensions;
+using Remora.Discord.Commands.Services;
+using Remora.Discord.Gateway;
+using Remora.Discord.Gateway.Extensions;
+using Remora.Discord.Hosting.Extensions;
+using Remora.Discord.Interactivity.Extensions;
+using Remora.Rest.Core;
 
 namespace Boyfriend;
 
-public static class Boyfriend {
-    public static readonly StringBuilder StringBuilder = new();
+public class Boyfriend {
+    public static readonly AllowedMentions NoMentions = new(
+        Array.Empty<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&apos;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&apos;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} -&gt; {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&apos;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 &quot;{0}&quot; 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 &quot;{0}&quot; 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 &lt;t:{3}:R&gt;! \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 &lt;t:{2}:R&gt;!.
         /// </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 &quot;{0}&quot; 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&apos;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&apos;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&apos;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&apos;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 &lt;t:{0}:f&gt;.
-        /// </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&apos;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&apos;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&apos;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&apos;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&apos;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} -&gt; {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 &lt;t:{3}:R&gt;! \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 &lt;t:{0}:f&gt;</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} -&gt; {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} и начнётся &lt;t:{3}:R&gt;!{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>Хорошо, я упомяну тебя &lt;t:{0}:f&gt;</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} -&gt; {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} и начнётся &lt;t:{3}:R&gt;!{4}</value>
+    <value>{0} приготовил новую движуху {1}! она пройдёт в {2} и начнётся &lt;t:{3}:R&gt;!{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} начнется &lt;t:{2}:R&gt;!</value>
+    <value>{0}движуха {1} начнется &lt;t:{2}:R&gt;!</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>вас понял, упоминание будет &lt;t:{0}:f&gt;</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>
+
+![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend)
+![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master)
+![GitHub last commit](https://img.shields.io/github/last-commit/TeamOctolings/Boyfriend)
+![CodeFactor](https://img.shields.io/codefactor/grade/github/TeamOctolings/Boyfriend)
+
+Beep! I'm a general-purpose bot for moderation written by [@Octol1ttle](https://github.com/Octol1ttle) in C# and
+Discord.Net
+
+## Features
+
+* Banning, muting, kicking, etc.
+* Reminding you about something if you wish
+* Reminding everyone about that new event you made
+* Log everything from joining the server to deleting messages
+
+*...and more!*
+
+## Installing and running Boyfriend
+
+You can read our [wiki](https://github.com/TeamOctolings/Boyfriend/wiki) in order to assemble your Boyfriend™ and
+moderate the server.
+
+## Contributing
+
+When it comes to contributing to the project, the two main things you can do to help out are reporting issues and
+submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in
+the most effective way possible.
+
+## Special Thanks
+
+![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)
+
+[JetBrains](https://www.jetbrains.com/), creators of [ReSharper](https://www.jetbrains.com/resharper)
+and [Rider](https://www.jetbrains.com/rider), supports Boyfriend with one of
+their [Open Source Licenses](https://jb.gg/OpenSourceSupport).
+Rider is the recommended IDE when working with Boyfriend, and most of the Boyfriend team uses it.
+Additionally, ReSharper command-line tools made by JetBrains are used for status checks on pull requests to ensure code
+quality even when not using ReSharper or Rider.