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 @@
-
-
-![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend)
-![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master)
-![GitHub last commit](https://img.shields.io/github/last-commit/TeamOctolings/Boyfriend)
-
-Beep! I'm a general-purpose bot for moderation written by [@Octol1ttle](https://github.com/Octol1ttle) in C# and Discord.Net
-
-# Features
-* Banning, muting, kicking, etc.
-* Reminding you about something if you wish
-* Reminding everyone about that new event you made
-* Log everything from joining the server to deleting messages
-
-*...and more!*
-
-# Getting Started
-
-You can read our [wiki](https://github.com/TeamOctolings/Boyfriend/wiki) in order to assemble your Boyfriend™ and moderate the server.
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index 36ae7a7..0000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: "CodeQL"
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-on:
- push:
- branches: [ "master" ]
- pull_request:
- branches: [ "master" ]
- schedule:
- - cron: '45 7 * * 2'
-
-jobs:
- analyze:
- name: Analyze code
- runs-on: ubuntu-latest
- permissions:
- actions: read
- contents: read
- security-events: write
-
- strategy:
- fail-fast: false
- matrix:
- language: [ 'csharp' ]
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v3
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v2
- with:
- languages: ${{ matrix.language }}
- queries: +security-extended,security-and-quality
-
- - name: Build solution
- uses: github/codeql-action/autobuild@v2
-
- - name: Perform CodeQL analysis
- uses: github/codeql-action/analyze@v2
- with:
- category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/resharper.yml b/.github/workflows/resharper.yml
index a9cb03c..82b2562 100644
--- a/.github/workflows/resharper.yml
+++ b/.github/workflows/resharper.yml
@@ -18,9 +18,6 @@ jobs:
contents: read
security-events: write
- strategy:
- fail-fast: false
-
steps:
- name: Checkout repository
uses: actions/checkout@v3
@@ -29,8 +26,8 @@ jobs:
run: dotnet restore
- name: ReSharper CLI InspectCode
- uses: muno92/resharper_inspectcode@1.6.13
+ uses: muno92/resharper_inspectcode@1.7.1
with:
solutionPath: ./Boyfriend.sln
- ignoreIssueType: InvertIf
+ ignoreIssueType: InvertIf, ConvertIfStatementToReturnStatement, ConvertIfStatementToSwitchStatement
solutionWideAnalysis: true
diff --git a/.gitignore b/.gitignore
index 3816d9a..4529511 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
.idea/
*.user
-token.txt
bin/
obj/
/packages/
diff --git a/Boyfriend.cs b/Boyfriend.cs
index aaad927..6af4326 100644
--- a/Boyfriend.cs
+++ b/Boyfriend.cs
@@ -1,180 +1,96 @@
-using System.Text;
-using System.Timers;
-using Boyfriend.Data;
-using Discord;
-using Discord.Rest;
-using Discord.WebSocket;
-using Timer = System.Timers.Timer;
+using Boyfriend.Commands;
+using Boyfriend.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Remora.Commands.Extensions;
+using Remora.Discord.API.Abstractions.Gateway.Commands;
+using Remora.Discord.API.Abstractions.Objects;
+using Remora.Discord.API.Objects;
+using Remora.Discord.Caching.Extensions;
+using Remora.Discord.Caching.Services;
+using Remora.Discord.Commands.Extensions;
+using Remora.Discord.Commands.Services;
+using Remora.Discord.Gateway;
+using Remora.Discord.Gateway.Extensions;
+using Remora.Discord.Hosting.Extensions;
+using Remora.Discord.Interactivity.Extensions;
+using Remora.Rest.Core;
namespace Boyfriend;
-public static class Boyfriend {
- public static readonly StringBuilder StringBuilder = new();
+public class Boyfriend {
+ public static readonly AllowedMentions NoMentions = new(
+ Array.Empty(), Array.Empty(), Array.Empty());
- private static readonly DiscordSocketConfig Config = new() {
- MessageCacheSize = 250,
- GatewayIntents
- = (GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent | GatewayIntents.GuildMembers)
- & ~GatewayIntents.GuildInvites,
- AlwaysDownloadUsers = true,
- AlwaysResolveStickers = false,
- AlwaysDownloadDefaultStickers = false,
- LargeThreshold = 500
- };
+ public static async Task Main(string[] args) {
+ var host = CreateHostBuilder(args).UseConsoleLifetime().Build();
+ var services = host.Services;
- private static DateTimeOffset _nextSongAt = DateTimeOffset.MinValue;
- private static uint _nextSongIndex;
+ var slashService = services.GetRequiredService();
+ // Providing a guild ID to this call will result in command duplicates!
+ // To get rid of them, provide the ID of the guild containing duplicates,
+ // comment out calls to WithCommandGroup in CreateHostBuilder
+ // then launch the bot again and remove the guild ID
+ await slashService.UpdateSlashCommandsAsync();
- private static readonly (Game Song, TimeSpan Duration)[] ActivityList = {
- (new Game("Masayoshi Minoshima (ft. nomico) - Bad Apple!!", ActivityType.Listening), new TimeSpan(0, 3, 40)),
- (new Game("Xi - Blue Zenith", ActivityType.Listening), new TimeSpan(0, 4, 16)),
- (new Game("UNDEAD CORPORATION - Everything will freeze", ActivityType.Listening), new TimeSpan(0, 3, 18)),
- (new Game("Splatoon 3 - Candy-Coated Rocks", ActivityType.Listening), new TimeSpan(0, 2, 39)),
- (new Game("RetroSpecter - Overtime", ActivityType.Listening), new TimeSpan(0, 4, 33)),
- (new Game("SOOOO - Happppy song", ActivityType.Listening), new TimeSpan(0, 5, 24))
- };
-
- public static readonly DiscordSocketClient Client = new(Config);
-
- private static readonly List GuildTickTasks = new();
-
- private static async Task Main() {
- var token = (await File.ReadAllTextAsync("token.txt")).Trim();
-
- Client.Log += Log;
-
- await Client.LoginAsync(TokenType.Bot, token);
- await Client.StartAsync();
-
- EventHandler.InitEvents();
-
- var timer = new Timer();
- timer.Interval = 1000;
- timer.AutoReset = true;
- timer.Elapsed += TickAllGuildsAsync;
- if (ActivityList.Length is 0) timer.Dispose(); // CodeQL moment
- timer.Start();
-
- await Task.Delay(-1);
+ await host.RunAsync();
}
- private static async void TickAllGuildsAsync(object? sender, ElapsedEventArgs e) {
- if (GuildTickTasks.Count is not 0) return;
+ private static IHostBuilder CreateHostBuilder(string[] args) {
+ return Host.CreateDefaultBuilder(args)
+ .AddDiscordService(
+ services => {
+ var configuration = services.GetRequiredService();
- var now = DateTimeOffset.UtcNow;
- foreach (var guild in Client.Guilds) GuildTickTasks.Add(TickGuildAsync(guild, now));
+ return configuration.GetValue("BOT_TOKEN")
+ ?? throw new InvalidOperationException(
+ "No bot token has been provided. Set the "
+ + "BOT_TOKEN environment variable to a valid token.");
+ }
+ ).ConfigureServices(
+ (_, services) => {
+ services.Configure(
+ options => options.Intents |= GatewayIntents.MessageContents
+ | GatewayIntents.GuildMembers
+ | GatewayIntents.GuildScheduledEvents);
+ services.Configure(
+ settings => {
+ settings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
+ settings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
+ settings.SetAbsoluteExpiration(TimeSpan.FromDays(7));
+ settings.SetSlidingExpiration(TimeSpan.FromDays(7));
+ });
- if (now >= _nextSongAt) {
- var nextSong = ActivityList[_nextSongIndex];
- await Client.SetActivityAsync(nextSong.Song);
- _nextSongAt = now.Add(nextSong.Duration);
- _nextSongIndex++;
- if (_nextSongIndex >= ActivityList.Length) _nextSongIndex = 0;
- }
-
- try { Task.WaitAll(GuildTickTasks.ToArray()); } catch (AggregateException ex) {
- foreach (var exc in ex.InnerExceptions)
- await Log(
- new LogMessage(
- LogSeverity.Error, nameof(Boyfriend),
- "Exception while ticking guilds", exc));
- }
-
- GuildTickTasks.Clear();
- }
-
- public static Task Log(LogMessage msg) {
- switch (msg.Severity) {
- case LogSeverity.Critical:
- Console.ForegroundColor = ConsoleColor.DarkRed;
- Console.Error.WriteLine(msg.ToString());
- break;
- case LogSeverity.Error:
- Console.ForegroundColor = ConsoleColor.Red;
- Console.Error.WriteLine(msg.ToString());
- break;
- case LogSeverity.Warning:
- Console.ForegroundColor = ConsoleColor.Yellow;
- Console.WriteLine(msg.ToString());
- break;
- case LogSeverity.Info:
- Console.WriteLine(msg.ToString());
- break;
-
- case LogSeverity.Verbose:
- case LogSeverity.Debug:
- default: return Task.CompletedTask;
- }
-
- Console.ResetColor();
- return Task.CompletedTask;
- }
-
- private static async Task TickGuildAsync(SocketGuild guild, DateTimeOffset now) {
- var data = GuildData.Get(guild);
- var config = data.Preferences;
- var saveData = false;
- _ = int.TryParse(config["EventEarlyNotificationOffset"], out var offset);
- foreach (var schEvent in guild.Events)
- if (schEvent.Status is GuildScheduledEventStatus.Scheduled
- && config["AutoStartEvents"] is "true"
- && DateTimeOffset
- .Now
- >= schEvent.StartTime) await schEvent.StartAsync();
- else if (!data.EarlyNotifications.Contains(schEvent.Id)
- && now >= schEvent.StartTime.Subtract(new TimeSpan(0, offset, 0))) {
- data.EarlyNotifications.Add(schEvent.Id);
- var receivers = config["EventStartedReceivers"];
- var role = guild.GetRole(ulong.Parse(config["EventNotificationRole"]));
- var mentions = StringBuilder;
-
- if (receivers.Contains("role") && role is not null) mentions.Append($"{role.Mention} ");
- if (receivers.Contains("users") || receivers.Contains("interested"))
- mentions = (await schEvent.GetUsersAsync(15))
- .Where(user => role is null || !((RestGuildUser)user).RoleIds.Contains(role.Id))
- .Aggregate(mentions, (current, user) => current.Append($"{user.Mention} "));
-
- await Utils.GetEventNotificationChannel(guild)?.SendMessageAsync(
- string.Format(
- Messages.EventEarlyNotification,
- mentions,
- Utils.Wrap(schEvent.Name),
- schEvent.StartTime.ToUnixTimeSeconds().ToString()))!;
- mentions.Clear();
- }
-
- foreach (var mData in data.MemberData.Values) {
- var user = guild.GetUser(mData.Id);
- if (now >= mData.BannedUntil && await guild.GetBanAsync(mData.Id) is not null)
- _ = guild.RemoveBanAsync(mData.Id);
- if (!mData.IsInGuild) continue;
- if (mData.MutedUntil is null
- && ulong.TryParse(config["StarterRole"], out var starterRoleId)
- && guild.GetRole(starterRoleId) is not null
- && !mData.Roles.Contains(starterRoleId)) _ = user.AddRoleAsync(starterRoleId);
-
- if (now >= mData.MutedUntil) {
- saveData = await Utils.UnmuteMemberAsync(
- data, Client.CurrentUser.ToString(), user,
- Messages.PunishmentExpired);
- }
-
- for (var i = mData.Reminders.Count - 1; i >= 0; i--) {
- var reminder = mData.Reminders[i];
- if (now < reminder.RemindAt) continue;
-
- var channel = guild.GetTextChannel(reminder.ReminderChannel);
- var toSend = $"{ReplyEmojis.Reminder} {user.Mention} {Utils.Wrap(reminder.ReminderText)}";
- if (channel is not null)
- await channel.SendMessageAsync(toSend);
- else
- await Utils.SendDirectMessage(user, toSend);
-
- mData.Reminders.RemoveAt(i);
- saveData = true;
- }
- }
-
- if (saveData) await data.Save(true);
+ services.AddTransient()
+ .AddDiscordCaching()
+ .AddDiscordCommands(true)
+ .AddPreparationErrorEvent()
+ .AddPostExecutionEvent()
+ .AddInteractivity()
+ .AddInteractionGroup()
+ .AddSingleton()
+ .AddSingleton()
+ .AddHostedService()
+ .AddCommandTree()
+ .WithCommandGroup()
+ .WithCommandGroup()
+ .WithCommandGroup()
+ .WithCommandGroup()
+ .WithCommandGroup()
+ .WithCommandGroup()
+ .WithCommandGroup()
+ .WithCommandGroup();
+ var responderTypes = typeof(Boyfriend).Assembly
+ .GetExportedTypes()
+ .Where(t => t.IsResponder());
+ foreach (var responderType in responderTypes) services.AddResponder(responderType);
+ }
+ ).ConfigureLogging(
+ c => c.AddConsole()
+ .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
+ .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
+ );
}
}
diff --git a/Boyfriend.csproj b/Boyfriend.csproj
index 0192abe..29483d7 100644
--- a/Boyfriend.csproj
+++ b/Boyfriend.csproj
@@ -5,9 +5,9 @@
net7.0
enable
enable
- 1.0.0
+ 2.0.0
Boyfriend
- Octol1ttle, mctaylors
+ Octol1ttle, mctaylors, neroduckale
AGPLv3
https://github.com/TeamOctolings/Boyfriend
https://github.com/TeamOctolings/Boyfriend/blob/master/LICENSE
@@ -19,8 +19,31 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ Messages.resx
+
+
+
+
+
+
+ ResXFileCodeGenerator
+ Messages.Designer.cs
+
+
+
+
+
diff --git a/ColorsList.cs b/ColorsList.cs
new file mode 100644
index 0000000..bdd5bce
--- /dev/null
+++ b/ColorsList.cs
@@ -0,0 +1,18 @@
+using System.Drawing;
+
+namespace Boyfriend;
+
+///
+/// Contains all colors used in embeds.
+///
+public static class ColorsList {
+ public static readonly Color Default = Color.Gray;
+ public static readonly Color Red = Color.Firebrick;
+ public static readonly Color Green = Color.PaleGreen;
+ public static readonly Color Yellow = Color.Gold;
+ public static readonly Color Blue = Color.RoyalBlue;
+ public static readonly Color Magenta = Color.Orchid;
+ public static readonly Color Cyan = Color.LightSkyBlue;
+ public static readonly Color Black = Color.Black;
+ public static readonly Color White = Color.WhiteSmoke;
+}
diff --git a/CommandProcessor.cs b/CommandProcessor.cs
deleted file mode 100644
index bd8b53e..0000000
--- a/CommandProcessor.cs
+++ /dev/null
@@ -1,321 +0,0 @@
-using System.Text;
-using Boyfriend.Commands;
-using Boyfriend.Data;
-using Discord;
-using Discord.Commands;
-using Discord.WebSocket;
-
-namespace Boyfriend;
-
-public sealed class CommandProcessor {
- private static readonly string Mention = $"<@{Boyfriend.Client.CurrentUser.Id}>";
- private static readonly TimeSpan Infinity = TimeSpan.FromMilliseconds(-1);
-
- public static readonly ICommand[] Commands = {
- new BanCommand(), new ClearCommand(), new HelpCommand(),
- new KickCommand(), new MuteCommand(), new PingCommand(),
- new RemindCommand(), new SettingsCommand(), new UnbanCommand(),
- new UnmuteCommand()
- };
-
- private readonly StringBuilder _stackedPrivateFeedback = new();
- private readonly StringBuilder _stackedPublicFeedback = new();
- private readonly StringBuilder _stackedReplyMessage = new();
- private readonly List _tasks = new();
-
- public readonly SocketCommandContext Context;
-
- public bool ConfigWriteScheduled = false;
-
- public CommandProcessor(SocketUserMessage message) {
- Context = new SocketCommandContext(Boyfriend.Client, message);
- }
-
- public async Task HandleCommandAsync() {
- var guild = Context.Guild;
- var data = GuildData.Get(guild);
- Utils.SetCurrentLanguage(guild);
-
- var list = Context.Message.Content.Split("\n");
- var cleanList = Context.Message.CleanContent.Split("\n");
- for (var i = 0; i < list.Length; i++)
- _tasks.Add(RunCommandOnLine(list[i], cleanList[i], data.Preferences["Prefix"]));
-
- try { Task.WaitAll(_tasks.ToArray()); } catch (AggregateException e) {
- foreach (var ex in e.InnerExceptions)
- await Boyfriend.Log(
- new LogMessage(
- LogSeverity.Error, nameof(CommandProcessor),
- "Exception while executing commands", ex));
- }
-
- _tasks.Clear();
-
- if (ConfigWriteScheduled) await data.Save(true);
-
- SendFeedbacks();
- }
-
- private async Task RunCommandOnLine(string line, string cleanLine, string prefix) {
- var prefixed = line.StartsWith(prefix);
- if (!prefixed && !line.StartsWith(Mention)) return;
- foreach (var command in Commands) {
- var lineNoMention = line.Remove(0, prefixed ? prefix.Length : Mention.Length);
- if (!command.Aliases.Contains(lineNoMention.Trim().Split()[0])) continue;
-
- var args = lineNoMention.Trim().Split().Skip(1).ToArray();
- var cleanArgs = cleanLine.Split().Skip(lineNoMention.StartsWith(" ") ? 2 : 1).ToArray();
- await command.RunAsync(this, args, cleanArgs);
- if (_stackedReplyMessage.Length > 0) _ = Context.Channel.TriggerTypingAsync();
- return;
- }
- }
-
- public void Reply(string response, string? customEmoji = null) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage, $"{customEmoji ?? ReplyEmojis.Success} {response}",
- Context.Message);
- }
-
- public void Audit(string action, bool isPublic = true) {
- var format = $"*[{Context.User.Mention}: {action}]*";
- var data = GuildData.Get(Context.Guild);
- if (isPublic) Utils.SafeAppendToBuilder(_stackedPublicFeedback, format, data.PublicFeedbackChannel);
- Utils.SafeAppendToBuilder(_stackedPrivateFeedback, format, data.PrivateFeedbackChannel);
- if (_tasks.Count is 0) SendFeedbacks(false);
- }
-
- private void SendFeedbacks(bool reply = true) {
- var hasReply = _stackedReplyMessage.Length > 0;
- if (reply && hasReply)
- _ = Context.Message.ReplyAsync(_stackedReplyMessage.ToString(), false, null, AllowedMentions.None);
-
- var data = GuildData.Get(Context.Guild);
- var adminChannel = data.PrivateFeedbackChannel;
- var systemChannel = data.PublicFeedbackChannel;
- if (_stackedPrivateFeedback.Length > 0
- && adminChannel is not null
- && (adminChannel.Id != Context.Message.Channel.Id || !hasReply)) {
- _ = Utils.SilentSendAsync(adminChannel, _stackedPrivateFeedback.ToString());
- _stackedPrivateFeedback.Clear();
- }
-
- if (_stackedPublicFeedback.Length > 0
- && systemChannel is not null
- && systemChannel.Id != adminChannel?.Id
- && (systemChannel.Id != Context.Message.Channel.Id || !hasReply)) {
- _ = Utils.SilentSendAsync(systemChannel, _stackedPublicFeedback.ToString());
- _stackedPublicFeedback.Clear();
- }
- }
-
- public string? GetRemaining(string[] from, int startIndex, string? argument) {
- if (startIndex >= from.Length && argument is not null)
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.MissingArgument} {Utils.GetMessage($"Missing{argument}")}", Context.Message);
- else return string.Join(" ", from, startIndex, from.Length - startIndex);
- return null;
- }
-
- public (ulong Id, SocketUser? User)? GetUser(string[] args, string[] cleanArgs, int index) {
- if (index >= args.Length) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingUser}",
- Context.Message);
- return null;
- }
-
- var mention = Utils.ParseMention(args[index]);
- if (mention is 0) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.InvalidArgument} {string.Format(Messages.InvalidUser, Utils.Wrap(cleanArgs[index]))}",
- Context.Message);
- return null;
- }
-
- var exists = Utils.UserExists(mention);
- if (!exists) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.Error} {string.Format(Messages.UserNotFound, Utils.Wrap(cleanArgs[index]))}",
- Context.Message);
- return null;
- }
-
- return (mention, Boyfriend.Client.GetUser(mention));
- }
-
- public bool HasPermission(GuildPermission permission) {
- if (!Context.Guild.CurrentUser.GuildPermissions.Has(permission)) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.NoPermission} {Utils.GetMessage($"BotCannot{permission}")}",
- Context.Message);
- return false;
- }
-
- if (!GetMember().GuildPermissions.Has(permission)
- && Context.Guild.OwnerId != Context.User.Id) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.NoPermission} {Utils.GetMessage($"UserCannot{permission}")}",
- Context.Message);
- return false;
- }
-
- return true;
- }
-
- private SocketGuildUser GetMember() {
- return GetMember(Context.User.Id)!;
- }
-
- public SocketGuildUser? GetMember(ulong id) {
- return Context.Guild.GetUser(id);
- }
-
- public SocketGuildUser? GetMember(string[] args, int index) {
- if (index >= args.Length) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingMember}",
- Context.Message);
- return null;
- }
-
- var member = Context.Guild.GetUser(Utils.ParseMention(args[index]));
- if (member is null)
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.InvalidArgument} {Messages.InvalidMember}",
- Context.Message);
- return member;
- }
-
- public ulong? GetBan(string[] args, int index) {
- if (index >= args.Length) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingUser}",
- Context.Message);
- return null;
- }
-
- var id = Utils.ParseMention(args[index]);
- if (Context.Guild.GetBanAsync(id) is null) {
- Utils.SafeAppendToBuilder(_stackedReplyMessage, Messages.UserNotBanned, Context.Message);
- return null;
- }
-
- return id;
- }
-
- public int? GetNumberRange(string[] args, int index, int min, int max, string? argument) {
- if (index >= args.Length) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.MissingArgument} {string.Format(Messages.MissingNumber, min.ToString(), max.ToString())}",
- Context.Message);
- return null;
- }
-
- if (!int.TryParse(args[index], out var i)) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}Invalid"), min.ToString(), max.ToString(), Utils.Wrap(args[index]))}",
- Context.Message);
- return null;
- }
-
- if (argument is null) return i;
- if (i < min) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}TooSmall"), min.ToString())}",
- Context.Message);
- return null;
- }
-
- if (i > max) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}TooLarge"), max.ToString())}",
- Context.Message);
- return null;
- }
-
- return i;
- }
-
- public static TimeSpan GetTimeSpan(string[] args, int index) {
- if (index >= args.Length) return Infinity;
- var chars = args[index].AsSpan();
- var numberBuilder = Boyfriend.StringBuilder;
- int days = 0, hours = 0, minutes = 0, seconds = 0;
- foreach (var c in chars)
- if (char.IsDigit(c)) { numberBuilder.Append(c); } else {
- if (numberBuilder.Length is 0) return Infinity;
- switch (c) {
- case 'd' or 'D' or 'д' or 'Д':
- days += int.Parse(numberBuilder.ToString());
- numberBuilder.Clear();
- break;
- case 'h' or 'H' or 'ч' or 'Ч':
- hours += int.Parse(numberBuilder.ToString());
- numberBuilder.Clear();
- break;
- case 'm' or 'M' or 'м' or 'М':
- minutes += int.Parse(numberBuilder.ToString());
- numberBuilder.Clear();
- break;
- case 's' or 'S' or 'с' or 'С':
- seconds += int.Parse(numberBuilder.ToString());
- numberBuilder.Clear();
- break;
- default: return Infinity;
- }
- }
-
- numberBuilder.Clear();
- return new TimeSpan(days, hours, minutes, seconds);
- }
-
- public bool CanInteractWith(SocketGuildUser user, string action) {
- if (Context.User.Id == user.Id) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Themselves")}", Context.Message);
- return false;
- }
-
- if (Context.Guild.CurrentUser.Id == user.Id) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Bot")}", Context.Message);
- return false;
- }
-
- if (Context.Guild.Owner.Id == user.Id) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Owner")}", Context.Message);
- return false;
- }
-
- if (Context.Guild.CurrentUser.Hierarchy <= user.Hierarchy) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"BotCannot{action}Target")}", Context.Message);
- return false;
- }
-
- if (Context.Guild.Owner.Id != Context.User.Id && GetMember().Hierarchy <= user.Hierarchy) {
- Utils.SafeAppendToBuilder(
- _stackedReplyMessage,
- $"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Target")}", Context.Message);
- return false;
- }
-
- return true;
- }
-}
diff --git a/Commands/AboutCommandGroup.cs b/Commands/AboutCommandGroup.cs
new file mode 100644
index 0000000..e4820c6
--- /dev/null
+++ b/Commands/AboutCommandGroup.cs
@@ -0,0 +1,75 @@
+using System.ComponentModel;
+using System.Text;
+using Boyfriend.Services;
+using Remora.Commands.Attributes;
+using Remora.Commands.Groups;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.Commands.Contexts;
+using Remora.Discord.Commands.Feedback.Services;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Extensions.Formatting;
+using Remora.Results;
+
+// ReSharper disable ClassNeverInstantiated.Global
+// ReSharper disable UnusedMember.Global
+
+namespace Boyfriend.Commands;
+
+///
+/// Handles the command to show information about this bot: /about.
+///
+public class AboutCommandGroup : CommandGroup {
+ private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" };
+ private readonly ICommandContext _context;
+ private readonly GuildDataService _dataService;
+ private readonly FeedbackService _feedbackService;
+ private readonly IDiscordRestUserAPI _userApi;
+
+ public AboutCommandGroup(
+ ICommandContext context, GuildDataService dataService,
+ FeedbackService feedbackService, IDiscordRestUserAPI userApi) {
+ _context = context;
+ _dataService = dataService;
+ _feedbackService = feedbackService;
+ _userApi = userApi;
+ }
+
+ ///
+ /// A slash command that shows information about this bot.
+ ///
+ ///
+ /// A feedback sending result which may or may not have succeeded.
+ ///
+ [Command("about")]
+ [Description("Shows Boyfriend's developers")]
+ public async Task SendAboutBotAsync() {
+ if (!_context.TryGetContextIDs(out var guildId, out _, out _))
+ return Result.FromError(
+ new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
+
+ var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
+ if (!currentUserResult.IsDefined(out var currentUser))
+ return Result.FromError(currentUserResult);
+
+ var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
+ Messages.Culture = cfg.GetCulture();
+
+ var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers));
+ foreach (var dev in Developers)
+ builder.AppendLine($"@{dev} — {$"AboutDeveloper@{dev}".Localized()}");
+
+ builder.AppendLine()
+ .AppendLine(Markdown.Bold(Messages.AboutTitleWiki))
+ .AppendLine("https://github.com/TeamOctolings/Boyfriend/wiki");
+
+ var embed = new EmbedBuilder().WithSmallTitle(Messages.AboutBot, currentUser)
+ .WithDescription(builder.ToString())
+ .WithColour(ColorsList.Cyan)
+ .WithImageUrl(
+ "https://media.discordapp.net/attachments/837385840946053181/1125009665592393738/boyfriend.png")
+ .Build();
+ if (!embed.IsDefined(out var built)) return Result.FromError(embed);
+
+ return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
+ }
+}
diff --git a/Commands/BanCommand.cs b/Commands/BanCommand.cs
deleted file mode 100644
index f12dbc7..0000000
--- a/Commands/BanCommand.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using Boyfriend.Data;
-using Discord;
-using Discord.WebSocket;
-
-namespace Boyfriend.Commands;
-
-public sealed class BanCommand : ICommand {
- public string[] Aliases { get; } = { "ban", "бан" };
-
- public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
- var toBan = cmd.GetUser(args, cleanArgs, 0);
- if (toBan is null || !cmd.HasPermission(GuildPermission.BanMembers)) return;
-
- var memberToBan = cmd.GetMember(toBan.Value.Id);
- if (memberToBan is not null && !cmd.CanInteractWith(memberToBan, "Ban")) return;
-
- var duration = CommandProcessor.GetTimeSpan(args, 1);
- var reason = cmd.GetRemaining(args, duration.TotalSeconds < 1 ? 1 : 2, "BanReason");
- if (reason is not null) await BanUserAsync(cmd, toBan.Value, duration, reason);
- }
-
- private static async Task BanUserAsync(
- CommandProcessor cmd, (ulong Id, SocketUser? User) toBan, TimeSpan duration,
- string reason) {
- var author = cmd.Context.User;
- var guild = cmd.Context.Guild;
- if (toBan.User is not null)
- await Utils.SendDirectMessage(
- toBan.User,
- string.Format(Messages.YouWereBanned, author.Mention, guild.Name, Utils.Wrap(reason)));
-
- var guildBanMessage = $"({author}) {reason}";
- await guild.AddBanAsync(toBan.Id, 0, guildBanMessage);
-
- var memberData = GuildData.Get(guild).MemberData[toBan.Id];
- memberData.BannedUntil
- = duration.TotalSeconds < 1 ? DateTimeOffset.MaxValue : DateTimeOffset.UtcNow.Add(duration);
- memberData.Roles.Clear();
-
- cmd.ConfigWriteScheduled = true;
-
- var feedback = string.Format(
- Messages.FeedbackUserBanned, $"<@{toBan.Id.ToString()}>",
- Utils.GetHumanizedTimeSpan(duration), Utils.Wrap(reason));
- cmd.Reply(feedback, ReplyEmojis.Banned);
- cmd.Audit(feedback);
- }
-}
diff --git a/Commands/BanCommandGroup.cs b/Commands/BanCommandGroup.cs
new file mode 100644
index 0000000..a525afb
--- /dev/null
+++ b/Commands/BanCommandGroup.cs
@@ -0,0 +1,275 @@
+using System.ComponentModel;
+using System.Text;
+using Boyfriend.Services;
+using Remora.Commands.Attributes;
+using Remora.Commands.Groups;
+using Remora.Discord.API.Abstractions.Objects;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.API.Objects;
+using Remora.Discord.Commands.Conditions;
+using Remora.Discord.Commands.Contexts;
+using Remora.Discord.Commands.Feedback.Services;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Extensions.Formatting;
+using Remora.Results;
+
+// ReSharper disable ClassNeverInstantiated.Global
+// ReSharper disable UnusedMember.Global
+
+namespace Boyfriend.Commands;
+
+///
+/// Handles commands related to ban management: /ban and /unban.
+///
+public class BanCommandGroup : CommandGroup {
+ private readonly IDiscordRestChannelAPI _channelApi;
+ private readonly ICommandContext _context;
+ private readonly GuildDataService _dataService;
+ private readonly FeedbackService _feedbackService;
+ private readonly IDiscordRestGuildAPI _guildApi;
+ private readonly IDiscordRestUserAPI _userApi;
+ private readonly UtilityService _utility;
+
+ public BanCommandGroup(
+ ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
+ FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi,
+ UtilityService utility) {
+ _context = context;
+ _channelApi = channelApi;
+ _dataService = dataService;
+ _feedbackService = feedbackService;
+ _guildApi = guildApi;
+ _userApi = userApi;
+ _utility = utility;
+ }
+
+ ///
+ /// A slash command that bans a Discord user with the specified reason.
+ ///
+ /// The user to ban.
+ /// The duration for this ban. The user will be automatically unbanned after this duration.
+ ///
+ /// The reason for this ban. Must be encoded with when passed to
+ /// .
+ ///
+ ///
+ /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
+ /// was banned and vice-versa.
+ ///
+ ///
+ [Command("ban", "бан")]
+ [RequireContext(ChannelContext.Guild)]
+ [RequireDiscordPermission(DiscordPermission.BanMembers)]
+ [RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
+ [Description("Ban user")]
+ public async Task BanUserAsync(
+ [Description("User to ban")] IUser target,
+ [Description("Ban reason")] string reason,
+ [Description("Ban duration")]
+ TimeSpan? duration = null) {
+ if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
+ return Result.FromError(
+ new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
+
+ // The current user's avatar is used when sending error messages
+ var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
+ if (!currentUserResult.IsDefined(out var currentUser))
+ return Result.FromError(currentUserResult);
+
+ var data = await _dataService.GetData(guildId.Value, CancellationToken);
+ var cfg = data.Configuration;
+ Messages.Culture = data.Culture;
+
+ var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken);
+ if (existingBanResult.IsDefined()) {
+ var embed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, currentUser)
+ .WithColour(ColorsList.Red).Build();
+
+ if (!embed.IsDefined(out var alreadyBuilt))
+ return Result.FromError(embed);
+
+ return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken);
+ }
+
+ var interactionResult
+ = await _utility.CheckInteractionsAsync(guildId.Value, userId.Value, target.ID, "Ban", CancellationToken);
+ if (!interactionResult.IsSuccess)
+ return Result.FromError(interactionResult);
+
+ Result