1
0
Fork 1
mirror of https://github.com/TeamOctolings/Octobot.git synced 2025-05-03 04:29:54 +03:00

Switch to Remora.Discord (#41)

result checks go brrr

this also involves switching to using Discord's modern stuff like embeds
and interactions

and using brand-new for me programming concepts (dependency injection,
results)

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
Signed-off-by: mctaylors <95250141+mctaylors@users.noreply.github.com>
Co-authored-by: mctaylors <95250141+mctaylors@users.noreply.github.com>
Co-authored-by: nrdk <neroduck@vk.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Octol1ttle 2023-07-09 18:32:14 +05:00 committed by GitHub
parent 2ab7a07784
commit abbb58f801
Signed by: GitHub
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 5011 additions and 3021 deletions

View file

@ -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);
}
}

View file

@ -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);
}
}

275
Commands/BanCommandGroup.cs Normal file
View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -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;
}
}

View file

@ -1,7 +0,0 @@
namespace Boyfriend.Commands;
public interface ICommand {
public string[] Aliases { get; }
public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs);
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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";
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}