1
0
Fork 1
mirror of https://github.com/TeamOctolings/Octobot.git synced 2025-01-31 09:09:00 +03:00

The Milestone Commit (#48)

mctaylors:
- updated readme 7 times (and only adding new logo from /about)
-
[removed](aeeb3d4399)
bot footer from created event embed on the second try
-
[changed](4b9b91d9e4)
cdn from discord to upload.systems

Octol1ttle:
- Guild settings code has been overhauled. Instead of instances of a
`GuildConfiguration` class being (de-)serialized when used with listing
and setting options provided by reflection, there are now multiple
`Option` classes responsible for the type of option they are storing.
The classes support getting a value, validating and setting values with
Results, and getting a user-friendly representation of these values.
This makes use of polymorphism, providing clean and easier to use and
refactor code.
- Gateway event responders have been split into their own separate
files, which should make it easier to find and modify responders when
needed.
- Warning suppressions regarding unused and never instantiated classes
have been replaced by `[ImplicitUse]` annotations provided by
`JetBrains.Annotations`. This avoids hiding real issues and provides a
better way to suppress false warnings while being explicit.
- It is no longer possible to execute some slash commands if they are
run without the correct permissions
- Dependencies are now more explicitly defined

neroduckale:
 - Made easter eggs case-insensitive

---------

Signed-off-by: Macintosh II <95250141+mctaylors@users.noreply.github.com>
Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
Co-authored-by: Octol1ttle <l1ttleofficial@outlook.com>
Co-authored-by: nrdk <neroduck@vk.com>
This commit is contained in:
Macintxsh 2023-07-18 15:25:02 +03:00 committed by GitHub
parent 3eb17b96c5
commit c6dd3727c3
Signed by: GitHub
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 912 additions and 658 deletions

View file

@ -4,10 +4,12 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
on: on:
push:
branches: [ "master" ]
pull_request: pull_request:
branches: [ "master" ] branches: [ "master" ]
merge_group:
types: [checks_requested]
push:
branches: [ "master" ]
jobs: jobs:
inspect-code: inspect-code:

View file

@ -21,8 +21,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="DiffPlex" Version="1.7.1"/> <PackageReference Include="DiffPlex" Version="1.7.1"/>
<PackageReference Include="Humanizer.Core.ru" Version="2.14.1"/> <PackageReference Include="Humanizer.Core.ru" Version="2.14.1"/>
<PackageReference Include="JetBrains.Annotations" Version="2023.2.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1"/> <PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1"/>
<PackageReference Include="Remora.Discord" Version="2023.3.0"/> <PackageReference Include="Remora.Discord.Caching" Version="36.0.0"/>
<PackageReference Include="Remora.Discord.Extensions" Version="5.3.1"/>
<PackageReference Include="Remora.Discord.Hosting" Version="6.0.6"/>
<PackageReference Include="Remora.Discord.Interactivity" Version="4.5.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Update="locale\Messages.resx"> <EmbeddedResource Update="locale\Messages.resx">

View file

@ -1,12 +1,6 @@
<picture> <p align="center">
<img src="https://cdn.upload.systems/uploads/v40uV9K1.png" alt="Boyfriend logo" width="75%"/>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png"> </p>
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/95250141/206895340-3415d97d-91fd-4fb6-8c17-4e1bf340e1df.png">
<img alt="Boyfriend Logo" src="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png">
</picture>
![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend) ![GitHub 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 Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master)

BIN
docs/assets/boyfriend.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

View file

@ -56,23 +56,28 @@ public class Boyfriend {
| GatewayIntents.GuildMembers | GatewayIntents.GuildMembers
| GatewayIntents.GuildScheduledEvents); | GatewayIntents.GuildScheduledEvents);
services.Configure<CacheSettings>( services.Configure<CacheSettings>(
settings => { cSettings => {
settings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
settings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
settings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7)); cSettings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
settings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7)); cSettings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7));
}); });
services.AddTransient<IConfigurationBuilder, ConfigurationBuilder>() services.AddTransient<IConfigurationBuilder, ConfigurationBuilder>()
// Init
.AddDiscordCaching() .AddDiscordCaching()
.AddDiscordCommands(true) .AddDiscordCommands(true)
.AddPreparationErrorEvent<ErrorLoggingPreparationErrorEvent>() // Interactions
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
.AddInteractivity() .AddInteractivity()
.AddInteractionGroup<InteractionResponders>() .AddInteractionGroup<InteractionResponders>()
// Slash command event handlers
.AddPreparationErrorEvent<ErrorLoggingPreparationErrorEvent>()
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
// Services
.AddSingleton<GuildDataService>() .AddSingleton<GuildDataService>()
.AddSingleton<UtilityService>() .AddSingleton<UtilityService>()
.AddHostedService<GuildUpdateService>() .AddHostedService<GuildUpdateService>()
// Slash commands
.AddCommandTree() .AddCommandTree()
.WithCommandGroup<AboutCommandGroup>() .WithCommandGroup<AboutCommandGroup>()
.WithCommandGroup<BanCommandGroup>() .WithCommandGroup<BanCommandGroup>()

View file

@ -1,6 +1,8 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using Boyfriend.Data;
using Boyfriend.Services; using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
@ -10,14 +12,12 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles the command to show information about this bot: /about. /// Handles the command to show information about this bot: /about.
/// </summary> /// </summary>
[UsedImplicitly]
public class AboutCommandGroup : CommandGroup { public class AboutCommandGroup : CommandGroup {
private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" }; private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" };
private readonly ICommandContext _context; private readonly ICommandContext _context;
@ -42,6 +42,7 @@ public class AboutCommandGroup : CommandGroup {
/// </returns> /// </returns>
[Command("about")] [Command("about")]
[Description("Shows Boyfriend's developers")] [Description("Shows Boyfriend's developers")]
[UsedImplicitly]
public async Task<Result> SendAboutBotAsync() { public async Task<Result> SendAboutBotAsync() {
if (!_context.TryGetContextIDs(out var guildId, out _, out _)) if (!_context.TryGetContextIDs(out var guildId, out _, out _))
return Result.FromError( return Result.FromError(
@ -51,8 +52,8 @@ public class AboutCommandGroup : CommandGroup {
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture(); Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers)); var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers));
foreach (var dev in Developers) foreach (var dev in Developers)
@ -65,8 +66,7 @@ public class AboutCommandGroup : CommandGroup {
var embed = new EmbedBuilder().WithSmallTitle(Messages.AboutBot, currentUser) var embed = new EmbedBuilder().WithSmallTitle(Messages.AboutBot, currentUser)
.WithDescription(builder.ToString()) .WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan) .WithColour(ColorsList.Cyan)
.WithImageUrl( .WithImageUrl("https://cdn.upload.systems/uploads/JFAaX5vr.png")
"https://media.discordapp.net/attachments/837385840946053181/1125009665592393738/boyfriend.png")
.Build(); .Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed); if (!embed.IsDefined(out var built)) return Result.FromError(embed);

View file

@ -1,11 +1,14 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using Boyfriend.Data;
using Boyfriend.Services; using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions; using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
@ -13,14 +16,12 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles commands related to ban management: /ban and /unban. /// Handles commands related to ban management: /ban and /unban.
/// </summary> /// </summary>
[UsedImplicitly]
public class BanCommandGroup : CommandGroup { public class BanCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
@ -58,10 +59,13 @@ public class BanCommandGroup : CommandGroup {
/// </returns> /// </returns>
/// <seealso cref="UnbanUserAsync" /> /// <seealso cref="UnbanUserAsync" />
[Command("ban", "бан")] [Command("ban", "бан")]
[DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.BanMembers)] [RequireDiscordPermission(DiscordPermission.BanMembers)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Ban user")] [Description("Ban user")]
[UsedImplicitly]
public async Task<Result> BanUserAsync( public async Task<Result> BanUserAsync(
[Description("User to ban")] IUser target, [Description("User to ban")] IUser target,
[Description("Ban reason")] string reason, [Description("Ban reason")] string reason,
@ -76,8 +80,8 @@ public class BanCommandGroup : CommandGroup {
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var data = await _dataService.GetData(guildId.Value, CancellationToken); var data = await _dataService.GetData(guildId.Value, CancellationToken);
var cfg = data.Configuration; var cfg = data.Settings;
Messages.Culture = data.Culture; Messages.Culture = GuildSettings.Language.Get(cfg);
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken); var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken);
if (existingBanResult.IsDefined()) { if (existingBanResult.IsDefined()) {
@ -145,8 +149,10 @@ public class BanCommandGroup : CommandGroup {
string.Format(Messages.UserBanned, target.GetTag()), target) string.Format(Messages.UserBanned, target.GetTag()), target)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
var logEmbed = new EmbedBuilder().WithSmallTitle( var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserBanned, target.GetTag()), target) string.Format(Messages.UserBanned, target.GetTag()), target)
.WithDescription(description) .WithDescription(description)
@ -160,14 +166,14 @@ public class BanCommandGroup : CommandGroup {
var builtArray = new[] { logBuilt }; var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time // Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value) if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
&& cfg.PrivateFeedbackChannel != channelId.Value) && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
} }
} }
@ -193,10 +199,13 @@ public class BanCommandGroup : CommandGroup {
/// <seealso cref="BanUserAsync" /> /// <seealso cref="BanUserAsync" />
/// <seealso cref="GuildUpdateService.TickGuildAsync"/> /// <seealso cref="GuildUpdateService.TickGuildAsync"/>
[Command("unban")] [Command("unban")]
[DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.BanMembers)] [RequireDiscordPermission(DiscordPermission.BanMembers)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Unban user")] [Description("Unban user")]
[UsedImplicitly]
public async Task<Result> UnbanUserAsync( public async Task<Result> UnbanUserAsync(
[Description("User to unban")] IUser target, [Description("User to unban")] IUser target,
[Description("Unban reason")] string reason) { [Description("Unban reason")] string reason) {
@ -209,8 +218,8 @@ public class BanCommandGroup : CommandGroup {
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture(); Messages.Culture = GuildSettings.Language.Get(cfg);
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken); var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken);
if (!existingBanResult.IsDefined()) { if (!existingBanResult.IsDefined()) {
@ -238,8 +247,10 @@ public class BanCommandGroup : CommandGroup {
string.Format(Messages.UserUnbanned, target.GetTag()), target) string.Format(Messages.UserUnbanned, target.GetTag()), target)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
var logEmbed = new EmbedBuilder().WithSmallTitle( var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnbanned, target.GetTag()), target) string.Format(Messages.UserUnbanned, target.GetTag()), target)
.WithDescription(string.Format(Messages.DescriptionActionReason, reason)) .WithDescription(string.Format(Messages.DescriptionActionReason, reason))
@ -254,14 +265,14 @@ public class BanCommandGroup : CommandGroup {
var builtArray = new[] { logBuilt }; var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time // Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value) if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
&& cfg.PrivateFeedbackChannel != channelId.Value) && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
} }

View file

@ -1,6 +1,8 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using Boyfriend.Data;
using Boyfriend.Services; using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -14,14 +16,12 @@ using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles the command to clear messages in a channel: /clear. /// Handles the command to clear messages in a channel: /clear.
/// </summary> /// </summary>
[UsedImplicitly]
public class ClearCommandGroup : CommandGroup { public class ClearCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
@ -48,10 +48,13 @@ public class ClearCommandGroup : CommandGroup {
/// were cleared and vice-versa. /// were cleared and vice-versa.
/// </returns> /// </returns>
[Command("clear", "очистить")] [Command("clear", "очистить")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)] [RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ManageMessages)] [RequireBotDiscordPermissions(DiscordPermission.ManageMessages)]
[Description("Remove multiple messages")] [Description("Remove multiple messages")]
[UsedImplicitly]
public async Task<Result> ClearMessagesAsync( public async Task<Result> ClearMessagesAsync(
[Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)] [Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)]
int amount) { int amount) {
@ -64,8 +67,8 @@ public class ClearCommandGroup : CommandGroup {
if (!messagesResult.IsDefined(out var messages)) if (!messagesResult.IsDefined(out var messages))
return Result.FromError(messagesResult); return Result.FromError(messagesResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture(); Messages.Culture = GuildSettings.Language.Get(cfg);
var idList = new List<Snowflake>(messages.Count); var idList = new List<Snowflake>(messages.Count);
var builder = new StringBuilder().AppendLine(Mention.Channel(channelId.Value)).AppendLine(); var builder = new StringBuilder().AppendLine(Mention.Channel(channelId.Value)).AppendLine();
@ -93,7 +96,8 @@ public class ClearCommandGroup : CommandGroup {
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var title = string.Format(Messages.MessagesCleared, amount.ToString()); var title = string.Format(Messages.MessagesCleared, amount.ToString());
if (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value) { if (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) {
var logEmbed = new EmbedBuilder().WithSmallTitle(title, currentUser) var logEmbed = new EmbedBuilder().WithSmallTitle(title, currentUser)
.WithDescription(description) .WithDescription(description)
.WithActionFooter(user) .WithActionFooter(user)
@ -105,9 +109,9 @@ public class ClearCommandGroup : CommandGroup {
return Result.FromError(logEmbed); return Result.FromError(logEmbed);
// Not awaiting to reduce response time // Not awaiting to reduce response time
if (cfg.PrivateFeedbackChannel != channelId.Value) if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { logBuilt }, GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { logBuilt },
ct: CancellationToken); ct: CancellationToken);
} }

View file

@ -1,15 +1,16 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Services; using Remora.Discord.Commands.Services;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles error logging for slash commands that couldn't be successfully prepared. /// Handles error logging for slash commands that couldn't be successfully prepared.
/// </summary> /// </summary>
[UsedImplicitly]
public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent { public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent {
private readonly ILogger<ErrorLoggingPreparationErrorEvent> _logger; private readonly ILogger<ErrorLoggingPreparationErrorEvent> _logger;
@ -27,8 +28,11 @@ public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent {
/// <returns>A result which has succeeded.</returns> /// <returns>A result which has succeeded.</returns>
public Task<Result> PreparationFailed( public Task<Result> PreparationFailed(
IOperationContext context, IResult preparationResult, CancellationToken ct = default) { IOperationContext context, IResult preparationResult, CancellationToken ct = default) {
if (!preparationResult.IsSuccess) if (!preparationResult.IsSuccess && !preparationResult.Error.IsUserOrEnvironmentError()) {
_logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message); _logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message);
if (preparationResult.Error is ExceptionError exerr)
_logger.LogError(exerr.Exception, "An exception has been thrown");
}
return Task.FromResult(Result.FromSuccess()); return Task.FromResult(Result.FromSuccess());
} }
@ -37,6 +41,7 @@ public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent {
/// <summary> /// <summary>
/// Handles error logging for slash command groups. /// Handles error logging for slash command groups.
/// </summary> /// </summary>
[UsedImplicitly]
public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent { public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent {
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger; private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
@ -54,8 +59,11 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent {
/// <returns>A result which has succeeded.</returns> /// <returns>A result which has succeeded.</returns>
public Task<Result> AfterExecutionAsync( public Task<Result> AfterExecutionAsync(
ICommandContext context, IResult commandResult, CancellationToken ct = default) { ICommandContext context, IResult commandResult, CancellationToken ct = default) {
if (!commandResult.IsSuccess) if (!commandResult.IsSuccess && !commandResult.Error.IsUserOrEnvironmentError()) {
_logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message); _logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message);
if (commandResult.Error is ExceptionError exerr)
_logger.LogError(exerr.Exception, "An exception has been thrown");
}
return Task.FromResult(Result.FromSuccess()); return Task.FromResult(Result.FromSuccess());
} }

View file

@ -1,24 +1,25 @@
using System.ComponentModel; using System.ComponentModel;
using Boyfriend.Data;
using Boyfriend.Services; using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions; using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles the command to kick members of a guild: /kick. /// Handles the command to kick members of a guild: /kick.
/// </summary> /// </summary>
[UsedImplicitly]
public class KickCommandGroup : CommandGroup { public class KickCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
@ -54,10 +55,13 @@ public class KickCommandGroup : CommandGroup {
/// was kicked and vice-versa. /// was kicked and vice-versa.
/// </returns> /// </returns>
[Command("kick", "кик")] [Command("kick", "кик")]
[DiscordDefaultMemberPermissions(DiscordPermission.KickMembers)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.KickMembers)] [RequireDiscordPermission(DiscordPermission.KickMembers)]
[RequireBotDiscordPermissions(DiscordPermission.KickMembers)] [RequireBotDiscordPermissions(DiscordPermission.KickMembers)]
[Description("Kick member")] [Description("Kick member")]
[UsedImplicitly]
public async Task<Result> KickUserAsync( public async Task<Result> KickUserAsync(
[Description("Member to kick")] IUser target, [Description("Member to kick")] IUser target,
[Description("Kick reason")] string reason) { [Description("Kick reason")] string reason) {
@ -71,8 +75,8 @@ public class KickCommandGroup : CommandGroup {
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var data = await _dataService.GetData(guildId.Value, CancellationToken); var data = await _dataService.GetData(guildId.Value, CancellationToken);
var cfg = data.Configuration; var cfg = data.Settings;
Messages.Culture = cfg.GetCulture(); Messages.Culture = GuildSettings.Language.Get(cfg);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken);
if (!memberResult.IsSuccess) { if (!memberResult.IsSuccess) {
@ -129,8 +133,10 @@ public class KickCommandGroup : CommandGroup {
string.Format(Messages.UserKicked, target.GetTag()), target) string.Format(Messages.UserKicked, target.GetTag()), target)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
var logEmbed = new EmbedBuilder().WithSmallTitle( var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserKicked, target.GetTag()), target) string.Format(Messages.UserKicked, target.GetTag()), target)
.WithDescription(string.Format(Messages.DescriptionActionReason, reason)) .WithDescription(string.Format(Messages.DescriptionActionReason, reason))
@ -144,14 +150,14 @@ public class KickCommandGroup : CommandGroup {
var builtArray = new[] { logBuilt }; var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time // Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value) if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
&& cfg.PrivateFeedbackChannel != channelId.Value) && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
} }
} }

View file

@ -1,11 +1,14 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using Boyfriend.Data;
using Boyfriend.Services; using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions; using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
@ -13,14 +16,12 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles commands related to mute management: /mute and /unmute. /// Handles commands related to mute management: /mute and /unmute.
/// </summary> /// </summary>
[UsedImplicitly]
public class MuteCommandGroup : CommandGroup { public class MuteCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
@ -58,10 +59,13 @@ public class MuteCommandGroup : CommandGroup {
/// </returns> /// </returns>
/// <seealso cref="UnmuteUserAsync" /> /// <seealso cref="UnmuteUserAsync" />
[Command("mute", "мут")] [Command("mute", "мут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ModerateMembers)] [RequireDiscordPermission(DiscordPermission.ModerateMembers)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Mute member")] [Description("Mute member")]
[UsedImplicitly]
public async Task<Result> MuteUserAsync( public async Task<Result> MuteUserAsync(
[Description("Member to mute")] IUser target, [Description("Member to mute")] IUser target,
[Description("Mute reason")] string reason, [Description("Mute reason")] string reason,
@ -93,8 +97,8 @@ public class MuteCommandGroup : CommandGroup {
return Result.FromError(interactionResult); return Result.FromError(interactionResult);
var data = await _dataService.GetData(guildId.Value, CancellationToken); var data = await _dataService.GetData(guildId.Value, CancellationToken);
var cfg = data.Configuration; var cfg = data.Settings;
Messages.Culture = data.Culture; Messages.Culture = GuildSettings.Language.Get(cfg);
Result<Embed> responseEmbed; Result<Embed> responseEmbed;
if (interactionResult.Entity is not null) { if (interactionResult.Entity is not null) {
@ -116,8 +120,10 @@ public class MuteCommandGroup : CommandGroup {
string.Format(Messages.UserMuted, target.GetTag()), target) string.Format(Messages.UserMuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason)) var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason))
.Append( .Append(
string.Format( string.Format(
@ -136,14 +142,14 @@ public class MuteCommandGroup : CommandGroup {
var builtArray = new[] { logBuilt }; var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time // Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value) if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
&& cfg.PrivateFeedbackChannel != channelId.Value) && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
} }
} }
@ -169,10 +175,13 @@ public class MuteCommandGroup : CommandGroup {
/// <seealso cref="MuteUserAsync" /> /// <seealso cref="MuteUserAsync" />
/// <seealso cref="GuildUpdateService.TickGuildAsync"/> /// <seealso cref="GuildUpdateService.TickGuildAsync"/>
[Command("unmute", "размут")] [Command("unmute", "размут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ModerateMembers)] [RequireDiscordPermission(DiscordPermission.ModerateMembers)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Unmute member")] [Description("Unmute member")]
[UsedImplicitly]
public async Task<Result> UnmuteUserAsync( public async Task<Result> UnmuteUserAsync(
[Description("Member to unmute")] IUser target, [Description("Member to unmute")] IUser target,
[Description("Unmute reason")] string reason) { [Description("Unmute reason")] string reason) {
@ -185,8 +194,8 @@ public class MuteCommandGroup : CommandGroup {
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture(); Messages.Culture = GuildSettings.Language.Get(cfg);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken);
if (!memberResult.IsSuccess) { if (!memberResult.IsSuccess) {
@ -220,8 +229,10 @@ public class MuteCommandGroup : CommandGroup {
string.Format(Messages.UserUnmuted, target.GetTag()), target) string.Format(Messages.UserUnmuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
var logEmbed = new EmbedBuilder().WithSmallTitle( var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnmuted, target.GetTag()), target) string.Format(Messages.UserUnmuted, target.GetTag()), target)
.WithDescription(string.Format(Messages.DescriptionActionReason, reason)) .WithDescription(string.Format(Messages.DescriptionActionReason, reason))
@ -236,14 +247,14 @@ public class MuteCommandGroup : CommandGroup {
var builtArray = new[] { logBuilt }; var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time // Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value) if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
&& cfg.PrivateFeedbackChannel != channelId.Value) && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
ct: CancellationToken); ct: CancellationToken);
} }

View file

@ -1,5 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using Boyfriend.Data;
using Boyfriend.Services; using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
@ -9,14 +11,12 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway; using Remora.Discord.Gateway;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping /// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping
/// </summary> /// </summary>
[UsedImplicitly]
public class PingCommandGroup : CommandGroup { public class PingCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client; private readonly DiscordGatewayClient _client;
@ -44,6 +44,7 @@ public class PingCommandGroup : CommandGroup {
/// </returns> /// </returns>
[Command("ping", "пинг")] [Command("ping", "пинг")]
[Description("Get bot latency")] [Description("Get bot latency")]
[UsedImplicitly]
public async Task<Result> SendPingAsync() { public async Task<Result> SendPingAsync() {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _))
return Result.FromError( return Result.FromError(
@ -53,8 +54,8 @@ public class PingCommandGroup : CommandGroup {
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture(); Messages.Culture = GuildSettings.Language.Get(cfg);
var latency = _client.Latency.TotalMilliseconds; var latency = _client.Latency.TotalMilliseconds;
if (latency is 0) { if (latency is 0) {

View file

@ -1,23 +1,23 @@
using System.ComponentModel; using System.ComponentModel;
using Boyfriend.Data; using Boyfriend.Data;
using Boyfriend.Services; using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles the command to manage reminders: /remind /// Handles the command to manage reminders: /remind
/// </summary> /// </summary>
[UsedImplicitly]
public class RemindCommandGroup : CommandGroup { public class RemindCommandGroup : CommandGroup {
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly GuildDataService _dataService;
@ -40,7 +40,9 @@ public class RemindCommandGroup : CommandGroup {
/// <param name="message">The text of the reminder.</param> /// <param name="message">The text of the reminder.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns> /// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("remind")] [Command("remind")]
[DiscordDefaultDMPermission(false)]
[Description("Create a reminder")] [Description("Create a reminder")]
[UsedImplicitly]
public async Task<Result> AddReminderAsync( public async Task<Result> AddReminderAsync(
[Description("After what period of time mention the reminder")] [Description("After what period of time mention the reminder")]
TimeSpan @in, TimeSpan @in,
@ -57,8 +59,8 @@ public class RemindCommandGroup : CommandGroup {
(await _dataService.GetMemberData(guildId.Value, userId.Value, CancellationToken)).Reminders.Add( (await _dataService.GetMemberData(guildId.Value, userId.Value, CancellationToken)).Reminders.Add(
new Reminder { new Reminder {
RemindAt = remindAt, At = remindAt,
Channel = channelId.Value, Channel = channelId.Value.Value,
Text = message Text = message
}); });

View file

@ -1,26 +1,44 @@
using System.ComponentModel; using System.ComponentModel;
using System.Reflection;
using System.Text; using System.Text;
using Boyfriend.Data; using Boyfriend.Data;
using Boyfriend.Data.Options;
using Boyfriend.Services; using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; 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.Contexts;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands; namespace Boyfriend.Commands;
/// <summary> /// <summary>
/// Handles the commands to list and modify per-guild settings: /settings and /settings list. /// Handles the commands to list and modify per-guild settings: /settings and /settings list.
/// </summary> /// </summary>
[UsedImplicitly]
public class SettingsCommandGroup : CommandGroup { public class SettingsCommandGroup : CommandGroup {
private static readonly IOption[] AllOptions = {
GuildSettings.Language,
GuildSettings.WelcomeMessage,
GuildSettings.ReceiveStartupMessages,
GuildSettings.RemoveRolesOnMute,
GuildSettings.ReturnRolesOnRejoin,
GuildSettings.AutoStartEvents,
GuildSettings.PublicFeedbackChannel,
GuildSettings.PrivateFeedbackChannel,
GuildSettings.EventNotificationChannel,
GuildSettings.DefaultRole,
GuildSettings.MuteRole,
GuildSettings.EventNotificationRole,
GuildSettings.EventEarlyNotificationOffset
};
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService; private readonly FeedbackService _feedbackService;
@ -36,13 +54,18 @@ public class SettingsCommandGroup : CommandGroup {
} }
/// <summary> /// <summary>
/// A slash command that lists current per-guild settings. /// A slash command that lists current per-guild GuildSettings.
/// </summary> /// </summary>
/// <returns> /// <returns>
/// A feedback sending result which may or may not have succeeded. /// A feedback sending result which may or may not have succeeded.
/// </returns> /// </returns>
[Command("settingslist")] [Command("settingslist")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Shows settings list for this server")] [Description("Shows settings list for this server")]
[UsedImplicitly]
public async Task<Result> ListSettingsAsync() { public async Task<Result> ListSettingsAsync() {
if (!_context.TryGetContextIDs(out var guildId, out _, out _)) if (!_context.TryGetContextIDs(out var guildId, out _, out _))
return Result.FromError( return Result.FromError(
@ -52,19 +75,15 @@ public class SettingsCommandGroup : CommandGroup {
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture(); Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder(); var builder = new StringBuilder();
foreach (var setting in typeof(GuildConfiguration).GetProperties()) { foreach (var option in AllOptions) {
builder.Append(Markdown.InlineCode(setting.Name)) builder.Append(Markdown.InlineCode(option.Name))
.Append(": "); .Append(": ");
var something = setting.GetValue(cfg); builder.AppendLine(option.Display(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) var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, currentUser)
@ -77,13 +96,18 @@ public class SettingsCommandGroup : CommandGroup {
} }
/// <summary> /// <summary>
/// A slash command that modifies per-guild settings. /// A slash command that modifies per-guild GuildSettings.
/// </summary> /// </summary>
/// <param name="setting">The setting to modify.</param> /// <param name="setting">The setting to modify.</param>
/// <param name="value">The new value of the setting.</param> /// <param name="value">The new value of the setting.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns> /// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("settings")] [Command("settings")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Change settings for this server")] [Description("Change settings for this server")]
[UsedImplicitly]
public async Task<Result> EditSettingsAsync( public async Task<Result> EditSettingsAsync(
[Description("The setting whose value you want to change")] [Description("The setting whose value you want to change")]
string setting, string setting,
@ -96,40 +120,16 @@ public class SettingsCommandGroup : CommandGroup {
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture(); Messages.Culture = GuildSettings.Language.Get(cfg);
PropertyInfo? property = null; var option = AllOptions.Single(
o => string.Equals(setting, o.Name, StringComparison.InvariantCultureIgnoreCase));
try { var setResult = option.Set(cfg, value);
foreach (var prop in typeof(GuildConfiguration).GetProperties()) if (!setResult.IsSuccess) {
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) var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, currentUser)
.WithDescription(e.Message) .WithDescription(setResult.Error.Message)
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
.Build(); .Build();
if (!failedEmbed.IsDefined(out var failedBuilt)) return Result.FromError(failedEmbed); if (!failedEmbed.IsDefined(out var failedBuilt)) return Result.FromError(failedEmbed);
@ -139,9 +139,9 @@ public class SettingsCommandGroup : CommandGroup {
var builder = new StringBuilder(); var builder = new StringBuilder();
builder.Append(Markdown.InlineCode(setting)) builder.Append(Markdown.InlineCode(option.Name))
.Append($" {Messages.SettingIsNow} ") .Append($" {Messages.SettingIsNow} ")
.Append(Markdown.InlineCode(value)); .Append(option.Display(cfg));
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingSuccessfullyChanged, currentUser) var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingSuccessfullyChanged, currentUser)
.WithDescription(builder.ToString()) .WithDescription(builder.ToString())

View file

@ -1,90 +0,0 @@
using System.Globalization;
using Remora.Discord.API.Abstractions.Objects;
namespace Boyfriend.Data;
/// <summary>
/// Stores per-guild settings that can be set by a member
/// with <see cref="DiscordPermission.ManageGuild" /> using the /settings command
/// </summary>
public class GuildConfiguration {
/// <summary>
/// Represents a scheduled event notification receiver.
/// </summary>
/// <remarks>
/// Used to selectively mention guild members when a scheduled event has started or is about to start.
/// </remarks>
public enum NotificationReceiver {
Interested,
Role
}
public static readonly Dictionary<string, CultureInfo> CultureInfoCache = new() {
{ "en", new CultureInfo("en-US") },
{ "ru", new CultureInfo("ru-RU") },
{ "mctaylors-ru", new CultureInfo("tt-RU") }
};
public string Language { get; set; } = "en";
/// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the server.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>No message will be sent if set to "off", "disable" or "disabled".</item>
/// <item><see cref="Messages.DefaultWelcomeMessage" /> will be sent if set to "default" or "reset"</item>
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberAddResponder" />
public string WelcomeMessage { get; set; } = "default";
/// <summary>
/// Controls whether or not the <see cref="Messages.Ready" /> message should be sent
/// in <see cref="PrivateFeedbackChannel" /> on startup.
/// </summary>
/// <seealso cref="GuildCreateResponder" />
public bool ReceiveStartupMessages { get; set; }
public bool RemoveRolesOnMute { get; set; }
/// <summary>
/// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back.
/// </summary>
/// <remarks>Roles will not be returned if the member left the guild because of /ban or /kick.</remarks>
public bool ReturnRolesOnRejoin { get; set; }
public bool AutoStartEvents { get; set; }
/// <summary>
/// Controls what channel should all public messages be sent to.
/// </summary>
public ulong PublicFeedbackChannel { get; set; }
/// <summary>
/// Controls what channel should all private, moderator-only messages be sent to.
/// </summary>
public ulong PrivateFeedbackChannel { get; set; }
public ulong EventNotificationChannel { get; set; }
public ulong DefaultRole { get; set; }
public ulong MuteRole { get; set; }
public ulong EventNotificationRole { get; set; }
/// <summary>
/// Controls what guild members should be mentioned when a scheduled event has started or is about to start.
/// </summary>
/// <seealso cref="NotificationReceiver" />
public List<NotificationReceiver> EventStartedReceivers { get; set; }
= new() { NotificationReceiver.Interested, NotificationReceiver.Role };
/// <summary>
/// Controls the amount of time before a scheduled event to send a reminder in <see cref="EventNotificationChannel" />.
/// </summary>
public TimeSpan EventEarlyNotificationOffset { get; set; } = TimeSpan.Zero;
// Do not convert this to a property, else serialization will be attempted
public CultureInfo GetCulture() {
return CultureInfoCache[Language];
}
}

View file

@ -1,4 +1,4 @@
using System.Globalization; using System.Text.Json.Nodes;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Boyfriend.Data; namespace Boyfriend.Data;
@ -8,29 +8,26 @@ namespace Boyfriend.Data;
/// </summary> /// </summary>
/// <remarks>This information is stored on disk as a JSON file.</remarks> /// <remarks>This information is stored on disk as a JSON file.</remarks>
public class GuildData { public class GuildData {
public readonly GuildConfiguration Configuration;
public readonly string ConfigurationPath;
public readonly Dictionary<ulong, MemberData> MemberData; public readonly Dictionary<ulong, MemberData> MemberData;
public readonly string MemberDataPath; public readonly string MemberDataPath;
public readonly Dictionary<ulong, ScheduledEventData> ScheduledEvents; public readonly Dictionary<ulong, ScheduledEventData> ScheduledEvents;
public readonly string ScheduledEventsPath; public readonly string ScheduledEventsPath;
public readonly JsonNode Settings;
public readonly string SettingsPath;
public GuildData( public GuildData(
GuildConfiguration configuration, string configurationPath, JsonNode settings, string settingsPath,
Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath, Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
Dictionary<ulong, MemberData> memberData, string memberDataPath) { Dictionary<ulong, MemberData> memberData, string memberDataPath) {
Configuration = configuration; Settings = settings;
ConfigurationPath = configurationPath; SettingsPath = settingsPath;
ScheduledEvents = scheduledEvents; ScheduledEvents = scheduledEvents;
ScheduledEventsPath = scheduledEventsPath; ScheduledEventsPath = scheduledEventsPath;
MemberData = memberData; MemberData = memberData;
MemberDataPath = memberDataPath; MemberDataPath = memberDataPath;
} }
public CultureInfo Culture => Configuration.GetCulture();
public MemberData GetMemberData(Snowflake userId) { public MemberData GetMemberData(Snowflake userId) {
if (MemberData.TryGetValue(userId.Value, out var existing)) return existing; if (MemberData.TryGetValue(userId.Value, out var existing)) return existing;

63
src/Data/GuildSettings.cs Normal file
View file

@ -0,0 +1,63 @@
using Boyfriend.Data.Options;
using Boyfriend.Responders;
using Remora.Discord.API.Abstractions.Objects;
namespace Boyfriend.Data;
/// <summary>
/// Contains all per-guild settings that can be set by a member
/// with <see cref="DiscordPermission.ManageGuild" /> using the /settings command
/// </summary>
public static class GuildSettings {
public static readonly LanguageOption Language = new("Language", "en");
/// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the server.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>No message will be sent if set to "off", "disable" or "disabled".</item>
/// <item><see cref="Messages.DefaultWelcomeMessage" /> will be sent if set to "default" or "reset"</item>
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberJoinedResponder" />
public static readonly Option<string> WelcomeMessage = new("WelcomeMessage", "default");
/// <summary>
/// Controls whether or not the <see cref="Messages.Ready" /> message should be sent
/// in <see cref="PrivateFeedbackChannel" /> on startup.
/// </summary>
/// <seealso cref="GuildLoadedResponder" />
public static readonly BoolOption ReceiveStartupMessages = new("ReceiveStartupMessages", false);
public static readonly BoolOption RemoveRolesOnMute = new("RemoveRolesOnMute", false);
/// <summary>
/// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back.
/// </summary>
/// <remarks>Roles will not be returned if the member left the guild because of /ban or /kick.</remarks>
public static readonly BoolOption ReturnRolesOnRejoin = new("ReturnRolesOnRejoin", false);
public static readonly BoolOption AutoStartEvents = new("AutoStartEvents", false);
/// <summary>
/// Controls what channel should all public messages be sent to.
/// </summary>
public static readonly SnowflakeOption PublicFeedbackChannel = new("PublicFeedbackChannel");
/// <summary>
/// Controls what channel should all private, moderator-only messages be sent to.
/// </summary>
public static readonly SnowflakeOption PrivateFeedbackChannel = new("PrivateFeedbackChannel");
public static readonly SnowflakeOption EventNotificationChannel = new("EventNotificationChannel");
public static readonly SnowflakeOption DefaultRole = new("DefaultRole");
public static readonly SnowflakeOption MuteRole = new("MuteRole");
public static readonly SnowflakeOption EventNotificationRole = new("EventNotificationRole");
/// <summary>
/// Controls the amount of time before a scheduled event to send a reminder in <see cref="EventNotificationChannel" />.
/// </summary>
public static readonly TimeSpanOption EventEarlyNotificationOffset = new(
"EventEarlyNotificationOffset", TimeSpan.Zero);
}

View file

@ -1,5 +1,3 @@
using Remora.Rest.Core;
namespace Boyfriend.Data; namespace Boyfriend.Data;
/// <summary> /// <summary>
@ -13,6 +11,6 @@ public class MemberData {
public ulong Id { get; } public ulong Id { get; }
public DateTimeOffset? BannedUntil { get; set; } public DateTimeOffset? BannedUntil { get; set; }
public List<Snowflake> Roles { get; set; } = new(); public List<ulong> Roles { get; set; } = new();
public List<Reminder> Reminders { get; } = new(); public List<Reminder> Reminders { get; } = new();
} }

View file

@ -0,0 +1,34 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace Boyfriend.Data.Options;
public class BoolOption : Option<bool> {
public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { }
public override string Display(JsonNode settings) {
return Get(settings) ? Messages.Yes : Messages.No;
}
public override Result Set(JsonNode settings, string from) {
if (!TryParseBool(from, out var value))
return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue));
settings[Name] = value;
return Result.FromSuccess();
}
private static bool TryParseBool(string from, out bool value) {
value = false;
switch (from) {
case "1" or "y" or "yes" or "д" or "да":
value = true;
return true;
case "0" or "n" or "no" or "н" or "не" or "нет":
value = false;
return true;
default:
return false;
}
}
}

View file

@ -0,0 +1,10 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace Boyfriend.Data.Options;
public interface IOption {
string Name { get; }
string Display(JsonNode settings);
Result Set(JsonNode settings, string from);
}

View file

@ -0,0 +1,35 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace Boyfriend.Data.Options;
/// <inheritdoc />
public class LanguageOption : Option<CultureInfo> {
private static readonly Dictionary<string, CultureInfo> CultureInfoCache = new() {
{ "en", new CultureInfo("en-US") },
{ "ru", new CultureInfo("ru-RU") },
{ "mctaylors-ru", new CultureInfo("tt-RU") }
};
public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { }
public override string Display(JsonNode settings) {
return Markdown.InlineCode(settings[Name]?.GetValue<string>() ?? "en");
}
/// <inheritdoc />
public override CultureInfo Get(JsonNode settings) {
var property = settings[Name];
return property != null ? CultureInfoCache[property.GetValue<string>()] : DefaultValue;
}
/// <inheritdoc />
public override Result Set(JsonNode settings, string from) {
if (!CultureInfoCache.ContainsKey(from.ToLowerInvariant()))
return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.LanguageNotSupported));
return base.Set(settings, from.ToLowerInvariant());
}
}

View file

@ -0,0 +1,46 @@
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace Boyfriend.Data.Options;
/// <summary>
/// Represents an per-guild option.
/// </summary>
/// <typeparam name="T">The type of the option.</typeparam>
public class Option<T> : IOption
where T : notnull {
internal readonly T DefaultValue;
public Option(string name, T defaultValue) {
Name = name;
DefaultValue = defaultValue;
}
public string Name { get; }
public virtual string Display(JsonNode settings) {
return Markdown.InlineCode(Get(settings).ToString()!);
}
/// <summary>
/// Sets the value of the option from a <see cref="string" /> to the provided JsonNode.
/// </summary>
/// <param name="settings">The <see cref="JsonNode" /> to set the value to.</param>
/// <param name="from">The string from which the new value of the option will be parsed.</param>
/// <returns>A value setting result which may or may not have succeeded.</returns>
public virtual Result Set(JsonNode settings, string from) {
settings[Name] = from;
return Result.FromSuccess();
}
/// <summary>
/// Gets the value of the option from the provided <paramref name="settings" />.
/// </summary>
/// <param name="settings">The <see cref="JsonNode" /> to get the value from.</param>
/// <returns>The value of the option.</returns>
public virtual T Get(JsonNode settings) {
var property = settings[Name];
return property != null ? property.GetValue<T>() : DefaultValue;
}
}

View file

@ -0,0 +1,27 @@
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
namespace Boyfriend.Data.Options;
public class SnowflakeOption : Option<Snowflake> {
public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { }
public override string Display(JsonNode settings) {
return Name.EndsWith("Channel") ? Mention.Channel(Get(settings)) : Mention.Role(Get(settings));
}
public override Snowflake Get(JsonNode settings) {
var property = settings[Name];
return property != null ? property.GetValue<ulong>().ToSnowflake() : DefaultValue;
}
public override Result Set(JsonNode settings, string from) {
if (!ulong.TryParse(from, out var parsed))
return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue));
settings[Name] = parsed;
return Result.FromSuccess();
}
}

View file

@ -0,0 +1,28 @@
using System.Text.Json.Nodes;
using Remora.Commands.Parsers;
using Remora.Results;
namespace Boyfriend.Data.Options;
public class TimeSpanOption : Option<TimeSpan> {
private static readonly TimeSpanParser Parser = new();
public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { }
public override TimeSpan Get(JsonNode settings) {
var property = settings[Name];
return property != null ? ParseTimeSpan(property.GetValue<string>()).Entity : DefaultValue;
}
public override Result Set(JsonNode settings, string from) {
if (!ParseTimeSpan(from).IsDefined(out var span))
return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue));
settings[Name] = span.ToString();
return Result.FromSuccess();
}
private static Result<TimeSpan> ParseTimeSpan(string from) {
return Parser.TryParseAsync(from).AsTask().GetAwaiter().GetResult();
}
}

View file

@ -1,9 +1,7 @@
using Remora.Rest.Core;
namespace Boyfriend.Data; namespace Boyfriend.Data;
public struct Reminder { public struct Reminder {
public DateTimeOffset RemindAt; public DateTimeOffset At;
public string Text; public string Text;
public Snowflake Channel; public ulong Channel;
} }

View file

@ -1,335 +0,0 @@
using Boyfriend.Data;
using Boyfriend.Services;
using DiffPlex.DiffBuilder;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Caching;
using Remora.Discord.Caching.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results;
// ReSharper disable UnusedType.Global
namespace Boyfriend;
/// <summary>
/// Handles sending a <see cref="Messages.Ready" /> message to a guild that has just initialized if that guild
/// has <see cref="GuildConfiguration.ReceiveStartupMessages" /> enabled
/// </summary>
public class GuildCreateResponder : IResponder<IGuildCreate> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly ILogger<GuildCreateResponder> _logger;
private readonly IDiscordRestUserAPI _userApi;
public GuildCreateResponder(
IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger<GuildCreateResponder> logger,
IDiscordRestUserAPI userApi) {
_channelApi = channelApi;
_dataService = dataService;
_logger = logger;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild isn't IAvailableGuild
var guild = gatewayEvent.Guild.AsT0;
_logger.LogInformation("Joined guild \"{Name}\"", guild.Name);
var guildConfig = await _dataService.GetConfiguration(guild.ID, ct);
if (!guildConfig.ReceiveStartupMessages)
return Result.FromSuccess();
if (guildConfig.PrivateFeedbackChannel is 0)
return Result.FromSuccess();
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
Messages.Culture = guildConfig.GetCulture();
var i = Random.Shared.Next(1, 4);
var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser)
.WithTitle($"Beep{i}".Localized())
.WithDescription(Messages.Ready)
.WithCurrentTimestamp()
.WithColour(ColorsList.Blue)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
guildConfig.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct);
}
}
/// <summary>
/// Handles logging the contents of a deleted message and the user who deleted the message
/// to a guild's <see cref="GuildConfiguration.PrivateFeedbackChannel" /> if one is set.
/// </summary>
public class MessageDeletedResponder : IResponder<IMessageDelete> {
private readonly IDiscordRestAuditLogAPI _auditLogApi;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestUserAPI _userApi;
public MessageDeletedResponder(
IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi,
GuildDataService dataService, IDiscordRestUserAPI userApi) {
_auditLogApi = auditLogApi;
_channelApi = channelApi;
_dataService = dataService;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess();
var guildConfiguration = await _dataService.GetConfiguration(guildId, ct);
if (guildConfiguration.PrivateFeedbackChannel is 0) return Result.FromSuccess();
var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess();
var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult);
var auditLog = auditLogPage.AuditLogEntries.Single();
if (!auditLog.Options.IsDefined(out var options))
return Result.FromError(new ArgumentNullError(nameof(auditLog.Options)));
var user = message.Author;
if (options.ChannelID == gatewayEvent.ChannelID
&& DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) {
var userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct);
if (!userResult.IsDefined(out user)) return Result.FromError(userResult);
}
Messages.Culture = guildConfiguration.GetCulture();
var embed = new EmbedBuilder()
.WithSmallTitle(
string.Format(
Messages.CachedMessageDeleted,
message.Author.GetTag()), message.Author)
.WithDescription(
$"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}")
.WithActionFooter(user)
.WithTimestamp(message.Timestamp)
.WithColour(ColorsList.Red)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
guildConfiguration.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}
/// <summary>
/// Handles logging the difference between an edited message's old and new content
/// to a guild's <see cref="GuildConfiguration.PrivateFeedbackChannel" /> if one is set.
/// </summary>
public class MessageEditedResponder : IResponder<IMessageUpdate> {
private readonly CacheService _cacheService;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestUserAPI _userApi;
public MessageEditedResponder(
CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
IDiscordRestUserAPI userApi) {
_cacheService = cacheService;
_channelApi = channelApi;
_dataService = dataService;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.GuildID.IsDefined(out var guildId))
return Result.FromSuccess();
var guildConfiguration = await _dataService.GetConfiguration(guildId, ct);
if (guildConfiguration.PrivateFeedbackChannel is 0)
return Result.FromSuccess();
if (!gatewayEvent.Content.IsDefined(out var newContent))
return Result.FromSuccess();
if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp))
return Result.FromSuccess(); // The message wasn't actually edited
if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID)));
if (!gatewayEvent.ID.IsDefined(out var messageId))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ID)));
var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
var messageResult = await _cacheService.TryGetValueAsync<IMessage>(
cacheKey, ct);
if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
if (message.Content == newContent) return Result.FromSuccess();
// Custom event responders are called earlier than responders responsible for message caching
// This means that subsequent edit logs may contain the wrong content
// We can work around this by evicting the message from the cache
await _cacheService.EvictAsync<IMessage>(cacheKey, ct);
// However, since we evicted the message, subsequent edits won't have a cached instance to work with
// Getting the message will put it back in the cache, resolving all issues
// We don't need to await this since the result is not needed
// NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages
// NOTE: Awaiting this might not even solve this if the same responder is called asynchronously
_ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
var diff = InlineDiffBuilder.Diff(message.Content, newContent);
Messages.Culture = guildConfiguration.GetCulture();
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
.WithDescription($"https://discord.com/channels/{guildId}/{channelId}/{messageId}\n{diff.AsMarkdown()}")
.WithUserFooter(currentUser)
.WithTimestamp(timestamp.Value)
.WithColour(ColorsList.Yellow)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
guildConfiguration.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}
/// <summary>
/// Handles sending a guild's <see cref="GuildConfiguration.WelcomeMessage" /> if one is set.
/// If <see cref="GuildConfiguration.ReturnRolesOnRejoin"/> is enabled, roles will be returned.
/// </summary>
/// <seealso cref="GuildConfiguration.WelcomeMessage" />
public class GuildMemberAddResponder : IResponder<IGuildMemberAdd> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestGuildAPI _guildApi;
public GuildMemberAddResponder(
IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) {
_channelApi = channelApi;
_dataService = dataService;
_guildApi = guildApi;
}
public async Task<Result> RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.User.IsDefined(out var user))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.User)));
var data = await _dataService.GetData(gatewayEvent.GuildID, ct);
var cfg = data.Configuration;
if (cfg.PublicFeedbackChannel is 0 || cfg.WelcomeMessage is "off" or "disable" or "disabled")
return Result.FromSuccess();
if (cfg.ReturnRolesOnRejoin) {
var result = await _guildApi.ModifyGuildMemberAsync(
gatewayEvent.GuildID, user.ID, roles: data.GetMemberData(user.ID).Roles, ct: ct);
if (!result.IsSuccess) return Result.FromError(result.Error);
}
Messages.Culture = data.Culture;
var welcomeMessage = cfg.WelcomeMessage is "default" or "reset"
? Messages.DefaultWelcomeMessage
: cfg.WelcomeMessage;
var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult);
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user)
.WithGuildFooter(guild)
.WithTimestamp(gatewayEvent.JoinedAt)
.WithColour(ColorsList.Green)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}
/// <summary>
/// Handles sending a notification when a scheduled event has been cancelled
/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
/// </summary>
public class GuildScheduledEventDeleteResponder : IResponder<IGuildScheduledEventDelete> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) {
_channelApi = channelApi;
_dataService = dataService;
}
public async Task<Result> RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) {
var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct);
guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value);
if (guildData.Configuration.EventNotificationChannel is 0)
return Result.FromSuccess();
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name))
.WithDescription(":(")
.WithColour(ColorsList.Red)
.WithCurrentTimestamp()
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
guildData.Configuration.EventNotificationChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct);
}
}
/// <summary>
/// Handles updating <see cref="MemberData.Roles" /> when a guild member is updated.
/// </summary>
public class GuildMemberUpdateResponder : IResponder<IGuildMemberUpdate> {
private readonly GuildDataService _dataService;
public GuildMemberUpdateResponder(GuildDataService dataService) {
_dataService = dataService;
}
public async Task<Result> RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) {
var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct);
memberData.Roles = gatewayEvent.Roles.ToList();
return Result.FromSuccess();
}
}
/// <summary>
/// Handles sending replies to easter egg messages.
/// </summary>
public class MessageCreateResponder : IResponder<IMessageCreate> {
private readonly IDiscordRestChannelAPI _channelApi;
public MessageCreateResponder(IDiscordRestChannelAPI channelApi) {
_channelApi = channelApi;
}
public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) {
_ = _channelApi.CreateMessageAsync(
gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content switch {
"whoami" => "`nobody`",
"сука !!" => "`root`",
"воооо" => "`removing /...`",
"пон" =>
"https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg",
"++++" => "#",
_ => default(Optional<string>)
});
return Task.FromResult(Result.FromSuccess());
}
}

View file

@ -170,7 +170,7 @@ public static class Extensions {
return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}"; return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}";
} }
public static Snowflake ToDiscordSnowflake(this ulong id) { public static Snowflake ToSnowflake(this ulong id) {
return DiscordSnowflake.New(id); return DiscordSnowflake.New(id);
} }
@ -190,4 +190,8 @@ public static class Extensions {
&& context.TryGetChannelID(out channelId) && context.TryGetChannelID(out channelId)
&& context.TryGetUserID(out userId); && context.TryGetUserID(out userId);
} }
public static bool Empty(this Snowflake snowflake) {
return snowflake.Value is 0;
}
} }

View file

@ -1,17 +1,16 @@
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Commands.Feedback.Messages; using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Interactivity; using Remora.Discord.Interactivity;
using Remora.Results; using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend; namespace Boyfriend;
/// <summary> /// <summary>
/// Handles responding to various interactions. /// Handles responding to various interactions.
/// </summary> /// </summary>
[UsedImplicitly]
public class InteractionResponders : InteractionGroup { public class InteractionResponders : InteractionGroup {
private readonly FeedbackService _feedbackService; private readonly FeedbackService _feedbackService;
@ -25,6 +24,7 @@ public class InteractionResponders : InteractionGroup {
/// <param name="state">The ID of the guild and scheduled event, encoded as "guildId:eventId".</param> /// <param name="state">The ID of the guild and scheduled event, encoded as "guildId:eventId".</param>
/// <returns>An ephemeral feedback sending result which may or may not have succeeded.</returns> /// <returns>An ephemeral feedback sending result which may or may not have succeeded.</returns>
[Button("scheduled-event-details")] [Button("scheduled-event-details")]
[UsedImplicitly]
public async Task<Result> OnStatefulButtonClicked(string? state = null) { public async Task<Result> OnStatefulButtonClicked(string? state = null) {
if (state is null) return Result.FromError(new ArgumentNullError(nameof(state))); if (state is null) return Result.FromError(new ArgumentNullError(nameof(state)));

View file

@ -8,9 +8,6 @@
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
namespace Boyfriend { namespace Boyfriend {
using System;
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()] [System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()]

View file

@ -0,0 +1,63 @@
using Boyfriend.Data;
using Boyfriend.Services;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Gateway.Events;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Boyfriend.Responders;
/// <summary>
/// Handles sending a <see cref="Ready" /> message to a guild that has just initialized if that guild
/// has <see cref="GuildSettings.ReceiveStartupMessages" /> enabled
/// </summary>
[UsedImplicitly]
public class GuildLoadedResponder : IResponder<IGuildCreate> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly ILogger<GuildLoadedResponder> _logger;
private readonly IDiscordRestUserAPI _userApi;
public GuildLoadedResponder(
IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger<GuildLoadedResponder> logger,
IDiscordRestUserAPI userApi) {
_channelApi = channelApi;
_dataService = dataService;
_logger = logger;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild is not IAvailableGuild
var guild = gatewayEvent.Guild.AsT0;
_logger.LogInformation("Joined guild \"{Name}\"", guild.Name);
var cfg = await _dataService.GetSettings(guild.ID, ct);
if (!GuildSettings.ReceiveStartupMessages.Get(cfg))
return Result.FromSuccess();
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
return Result.FromSuccess();
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
Messages.Culture = GuildSettings.Language.Get(cfg);
var i = Random.Shared.Next(1, 4);
var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser)
.WithTitle($"Beep{i}".Localized())
.WithDescription(Messages.Ready)
.WithCurrentTimestamp()
.WithColour(ColorsList.Blue)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct);
}
}

View file

@ -0,0 +1,65 @@
using Boyfriend.Data;
using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Boyfriend.Responders;
/// <summary>
/// Handles sending a guild's <see cref="GuildSettings.WelcomeMessage" /> if one is set.
/// If <see cref="GuildSettings.ReturnRolesOnRejoin" /> is enabled, roles will be returned.
/// </summary>
/// <seealso cref="GuildSettings.WelcomeMessage" />
[UsedImplicitly]
public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestGuildAPI _guildApi;
public GuildMemberJoinedResponder(
IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) {
_channelApi = channelApi;
_dataService = dataService;
_guildApi = guildApi;
}
public async Task<Result> RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.User.IsDefined(out var user))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.User)));
var data = await _dataService.GetData(gatewayEvent.GuildID, ct);
var cfg = data.Settings;
if (GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|| GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled")
return Result.FromSuccess();
if (GuildSettings.ReturnRolesOnRejoin.Get(cfg)) {
var result = await _guildApi.ModifyGuildMemberAsync(
gatewayEvent.GuildID, user.ID,
roles: data.GetMemberData(user.ID).Roles.ConvertAll(r => r.ToSnowflake()), ct: ct);
if (!result.IsSuccess) return Result.FromError(result.Error);
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var welcomeMessage = GuildSettings.WelcomeMessage.Get(cfg) is "default" or "reset"
? Messages.DefaultWelcomeMessage
: GuildSettings.WelcomeMessage.Get(cfg);
var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult);
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user)
.WithGuildFooter(guild)
.WithTimestamp(gatewayEvent.JoinedAt)
.WithColour(ColorsList.Green)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}

View file

@ -0,0 +1,26 @@
using Boyfriend.Data;
using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Boyfriend.Responders;
/// <summary>
/// Handles updating <see cref="MemberData.Roles" /> when a guild member is updated.
/// </summary>
[UsedImplicitly]
public class GuildMemberUpdateResponder : IResponder<IGuildMemberUpdate> {
private readonly GuildDataService _dataService;
public GuildMemberUpdateResponder(GuildDataService dataService) {
_dataService = dataService;
}
public async Task<Result> RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) {
var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct);
memberData.Roles = gatewayEvent.Roles.ToList().ConvertAll(r => r.Value);
return Result.FromSuccess();
}
}

View file

@ -0,0 +1,78 @@
using Boyfriend.Data;
using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Boyfriend.Responders;
/// <summary>
/// Handles logging the contents of a deleted message and the user who deleted the message
/// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary>
[UsedImplicitly]
public class MessageDeletedResponder : IResponder<IMessageDelete> {
private readonly IDiscordRestAuditLogAPI _auditLogApi;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestUserAPI _userApi;
public MessageDeletedResponder(
IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi,
GuildDataService dataService, IDiscordRestUserAPI userApi) {
_auditLogApi = auditLogApi;
_channelApi = channelApi;
_dataService = dataService;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess();
var cfg = await _dataService.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) return Result.FromSuccess();
var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess();
var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult);
var auditLog = auditLogPage.AuditLogEntries.Single();
if (!auditLog.Options.IsDefined(out var options))
return Result.FromError(new ArgumentNullError(nameof(auditLog.Options)));
var user = message.Author;
if (options.ChannelID == gatewayEvent.ChannelID
&& DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) {
var userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct);
if (!userResult.IsDefined(out user)) return Result.FromError(userResult);
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var embed = new EmbedBuilder()
.WithSmallTitle(
string.Format(
Messages.CachedMessageDeleted,
message.Author.GetTag()), message.Author)
.WithDescription(
$"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}")
.WithActionFooter(user)
.WithTimestamp(message.Timestamp)
.WithColour(ColorsList.Red)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}

View file

@ -0,0 +1,89 @@
using Boyfriend.Data;
using Boyfriend.Services;
using DiffPlex.DiffBuilder;
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Caching;
using Remora.Discord.Caching.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Boyfriend.Responders;
/// <summary>
/// Handles logging the difference between an edited message's old and new content
/// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary>
[UsedImplicitly]
public class MessageEditedResponder : IResponder<IMessageUpdate> {
private readonly CacheService _cacheService;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestUserAPI _userApi;
public MessageEditedResponder(
CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
IDiscordRestUserAPI userApi) {
_cacheService = cacheService;
_channelApi = channelApi;
_dataService = dataService;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.GuildID.IsDefined(out var guildId))
return Result.FromSuccess();
var cfg = await _dataService.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
return Result.FromSuccess();
if (!gatewayEvent.Content.IsDefined(out var newContent))
return Result.FromSuccess();
if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp))
return Result.FromSuccess(); // The message wasn't actually edited
if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID)));
if (!gatewayEvent.ID.IsDefined(out var messageId))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ID)));
var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
var messageResult = await _cacheService.TryGetValueAsync<IMessage>(
cacheKey, ct);
if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
if (message.Content == newContent) return Result.FromSuccess();
// Custom event responders are called earlier than responders responsible for message caching
// This means that subsequent edit logs may contain the wrong content
// We can work around this by evicting the message from the cache
await _cacheService.EvictAsync<IMessage>(cacheKey, ct);
// However, since we evicted the message, subsequent edits won't have a cached instance to work with
// Getting the message will put it back in the cache, resolving all issues
// We don't need to await this since the result is not needed
// NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages
// NOTE: Awaiting this might not even solve this if the same responder is called asynchronously
_ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
var diff = InlineDiffBuilder.Diff(message.Content, newContent);
Messages.Culture = GuildSettings.Language.Get(cfg);
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
.WithDescription($"https://discord.com/channels/{guildId}/{channelId}/{messageId}\n{diff.AsMarkdown()}")
.WithUserFooter(currentUser)
.WithTimestamp(timestamp.Value)
.WithColour(ColorsList.Yellow)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}

View file

@ -0,0 +1,34 @@
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results;
namespace Boyfriend.Responders;
/// <summary>
/// Handles sending replies to easter egg messages.
/// </summary>
[UsedImplicitly]
public class MessageCreateResponder : IResponder<IMessageCreate> {
private readonly IDiscordRestChannelAPI _channelApi;
public MessageCreateResponder(IDiscordRestChannelAPI channelApi) {
_channelApi = channelApi;
}
public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) {
_ = _channelApi.CreateMessageAsync(
gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content.ToLowerInvariant() switch {
"whoami" => "`nobody`",
"сука !!" => "`root`",
"воооо" => "`removing /...`",
"пон" => "https://cdn.upload.systems/uploads/2LNfUSwM.jpg",
"++++" => "#",
"осу" => "https://github.com/ppy/osu",
_ => default(Optional<string>)
});
return Task.FromResult(Result.FromSuccess());
}
}

View file

@ -0,0 +1,45 @@
using Boyfriend.Data;
using Boyfriend.Services;
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Boyfriend.Responders;
/// <summary>
/// Handles sending a notification when a scheduled event has been cancelled
/// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary>
[UsedImplicitly]
public class GuildScheduledEventDeleteResponder : IResponder<IGuildScheduledEventDelete> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) {
_channelApi = channelApi;
_dataService = dataService;
}
public async Task<Result> RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) {
var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct);
guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value);
if (GuildSettings.EventNotificationChannel.Get(guildData.Settings).Empty())
return Result.FromSuccess();
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name))
.WithDescription(":(")
.WithColour(ColorsList.Red)
.WithCurrentTimestamp()
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(guildData.Settings), embeds: new[] { built }, ct: ct);
}
}

View file

@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using Boyfriend.Data; using Boyfriend.Data;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
@ -36,8 +37,8 @@ public class GuildDataService : IHostedService {
private async Task SaveAsync(CancellationToken ct) { private async Task SaveAsync(CancellationToken ct) {
var tasks = new List<Task>(); var tasks = new List<Task>();
foreach (var data in _datas.Values) { foreach (var data in _datas.Values) {
await using var configStream = File.OpenWrite(data.ConfigurationPath); await using var settingsStream = File.OpenWrite(data.SettingsPath);
tasks.Add(JsonSerializer.SerializeAsync(configStream, data.Configuration, cancellationToken: ct)); tasks.Add(JsonSerializer.SerializeAsync(settingsStream, data.Settings, cancellationToken: ct));
await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath); await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath);
tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct)); tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct));
@ -58,17 +59,16 @@ public class GuildDataService : IHostedService {
private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default) { private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default) {
var idString = $"{guildId}"; var idString = $"{guildId}";
var memberDataPath = $"{guildId}/MemberData"; var memberDataPath = $"{guildId}/MemberData";
var configurationPath = $"{guildId}/Configuration.json"; var settingsPath = $"{guildId}/Settings.json";
var scheduledEventsPath = $"{guildId}/ScheduledEvents.json"; var scheduledEventsPath = $"{guildId}/ScheduledEvents.json";
if (!Directory.Exists(idString)) Directory.CreateDirectory(idString); if (!Directory.Exists(idString)) Directory.CreateDirectory(idString);
if (!Directory.Exists(memberDataPath)) Directory.CreateDirectory(memberDataPath); if (!Directory.Exists(memberDataPath)) Directory.CreateDirectory(memberDataPath);
if (!File.Exists(configurationPath)) await File.WriteAllTextAsync(configurationPath, "{}", ct); if (!File.Exists(settingsPath)) await File.WriteAllTextAsync(settingsPath, "{}", ct);
if (!File.Exists(scheduledEventsPath)) await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct); if (!File.Exists(scheduledEventsPath)) await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
await using var configurationStream = File.OpenRead(configurationPath); await using var settingsStream = File.OpenRead(settingsPath);
var configuration var jsonSettings
= JsonSerializer.DeserializeAsync<GuildConfiguration>( = JsonNode.Parse(settingsStream);
configurationStream, cancellationToken: ct);
await using var eventsStream = File.OpenRead(scheduledEventsPath); await using var eventsStream = File.OpenRead(scheduledEventsPath);
var events var events
@ -80,23 +80,23 @@ public class GuildDataService : IHostedService {
await using var dataStream = File.OpenRead(dataPath); await using var dataStream = File.OpenRead(dataPath);
var data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct); var data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
if (data is null) continue; if (data is null) continue;
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToDiscordSnowflake(), ct); var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToSnowflake(), ct);
if (memberResult.IsSuccess) if (memberResult.IsSuccess)
data.Roles = memberResult.Entity.Roles.ToList(); data.Roles = memberResult.Entity.Roles.ToList().ConvertAll(r => r.Value);
memberData.Add(data.Id, data); memberData.Add(data.Id, data);
} }
var finalData = new GuildData( var finalData = new GuildData(
await configuration ?? new GuildConfiguration(), configurationPath, jsonSettings ?? new JsonObject(), settingsPath,
await events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath, await events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
memberData, memberDataPath); memberData, memberDataPath);
while (!_datas.ContainsKey(guildId)) _datas.TryAdd(guildId, finalData); while (!_datas.ContainsKey(guildId)) _datas.TryAdd(guildId, finalData);
return finalData; return finalData;
} }
public async Task<GuildConfiguration> GetConfiguration(Snowflake guildId, CancellationToken ct = default) { public async Task<JsonNode> GetSettings(Snowflake guildId, CancellationToken ct = default) {
return (await GetData(guildId, ct)).Configuration; return (await GetData(guildId, ct)).Settings;
} }
public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) { public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) {

View file

@ -1,3 +1,4 @@
using System.Text.Json.Nodes;
using Boyfriend.Data; using Boyfriend.Data;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -94,9 +95,9 @@ public class GuildUpdateService : BackgroundService {
/// This method does the following: /// This method does the following:
/// <list type="bullet"> /// <list type="bullet">
/// <item>Automatically unbans users once their ban period has expired.</item> /// <item>Automatically unbans users once their ban period has expired.</item>
/// <item>Automatically grants members the guild's <see cref="GuildConfiguration.DefaultRole"/> if one is set.</item> /// <item>Automatically grants members the guild's <see cref="GuildSettings.DefaultRole"/> if one is set.</item>
/// <item>Sends reminders about an upcoming scheduled event.</item> /// <item>Sends reminders about an upcoming scheduled event.</item>
/// <item>Automatically starts scheduled events if <see cref="GuildConfiguration.AutoStartEvents"/> is enabled.</item> /// <item>Automatically starts scheduled events if <see cref="GuildSettings.AutoStartEvents"/> is enabled.</item>
/// <item>Sends scheduled event start notifications.</item> /// <item>Sends scheduled event start notifications.</item>
/// <item>Sends scheduled event completion notifications.</item> /// <item>Sends scheduled event completion notifications.</item>
/// <item>Sends reminders to members.</item> /// <item>Sends reminders to members.</item>
@ -114,15 +115,15 @@ public class GuildUpdateService : BackgroundService {
/// <param name="ct">The cancellation token for this operation.</param> /// <param name="ct">The cancellation token for this operation.</param>
private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) { private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) {
var data = await _dataService.GetData(guildId, ct); var data = await _dataService.GetData(guildId, ct);
Messages.Culture = data.Culture; Messages.Culture = GuildSettings.Language.Get(data.Settings);
var defaultRoleSnowflake = data.Configuration.DefaultRole.ToDiscordSnowflake(); var defaultRole = GuildSettings.DefaultRole.Get(data.Settings);
foreach (var memberData in data.MemberData.Values) { foreach (var memberData in data.MemberData.Values) {
var userId = memberData.Id.ToDiscordSnowflake(); var userId = memberData.Id.ToSnowflake();
if (defaultRoleSnowflake.Value is not 0 && !memberData.Roles.Contains(defaultRoleSnowflake)) if (defaultRole.Value is not 0 && !memberData.Roles.Contains(defaultRole.Value))
_ = _guildApi.AddGuildMemberRoleAsync( _ = _guildApi.AddGuildMemberRoleAsync(
guildId, userId, defaultRoleSnowflake, ct: ct); guildId, userId, defaultRole, ct: ct);
if (DateTimeOffset.UtcNow > memberData.BannedUntil) { if (DateTimeOffset.UtcNow > memberData.BannedUntil) {
var unbanResult = await _guildApi.RemoveGuildBanAsync( var unbanResult = await _guildApi.RemoveGuildBanAsync(
@ -139,7 +140,7 @@ public class GuildUpdateService : BackgroundService {
for (var i = memberData.Reminders.Count - 1; i >= 0; i--) { for (var i = memberData.Reminders.Count - 1; i >= 0; i--) {
var reminder = memberData.Reminders[i]; var reminder = memberData.Reminders[i];
if (DateTimeOffset.UtcNow < reminder.RemindAt) continue; if (DateTimeOffset.UtcNow < reminder.At) continue;
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.Reminder, user.GetTag()), user) string.Format(Messages.Reminder, user.GetTag()), user)
@ -151,7 +152,7 @@ public class GuildUpdateService : BackgroundService {
if (!embed.IsDefined(out var built)) continue; if (!embed.IsDefined(out var built)) continue;
var messageResult = await _channelApi.CreateMessageAsync( var messageResult = await _channelApi.CreateMessageAsync(
reminder.Channel, Mention.User(user), embeds: new[] { built }, ct: ct); reminder.Channel.ToSnowflake(), Mention.User(user), embeds: new[] { built }, ct: ct);
if (!messageResult.IsSuccess) if (!messageResult.IsSuccess)
_logger.LogWarning( _logger.LogWarning(
"Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message); "Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message);
@ -163,7 +164,7 @@ public class GuildUpdateService : BackgroundService {
var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
if (!eventsResult.IsDefined(out var events)) return; if (!eventsResult.IsDefined(out var events)) return;
if (data.Configuration.EventNotificationChannel is 0) return; if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) return;
foreach (var scheduledEvent in events) { foreach (var scheduledEvent in events) {
if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) { if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) {
@ -172,7 +173,7 @@ public class GuildUpdateService : BackgroundService {
var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value];
if (storedEvent.Status == scheduledEvent.Status) { if (storedEvent.Status == scheduledEvent.Status) {
if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) { if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) {
if (data.Configuration.AutoStartEvents if (GuildSettings.AutoStartEvents.Get(data.Settings)
&& scheduledEvent.Status is not GuildScheduledEventStatus.Active) { && scheduledEvent.Status is not GuildScheduledEventStatus.Active) {
var startResult = await _eventApi.ModifyGuildScheduledEventAsync( var startResult = await _eventApi.ModifyGuildScheduledEventAsync(
guildId, scheduledEvent.ID, guildId, scheduledEvent.ID,
@ -182,10 +183,11 @@ public class GuildUpdateService : BackgroundService {
"Error in automatic scheduled event start request.\n{ErrorMessage}", "Error in automatic scheduled event start request.\n{ErrorMessage}",
startResult.Error.Message); startResult.Error.Message);
} }
} else if (data.Configuration.EventEarlyNotificationOffset != TimeSpan.Zero } else if (GuildSettings.EventEarlyNotificationOffset.Get(data.Settings) != TimeSpan.Zero
&& !storedEvent.EarlyNotificationSent && !storedEvent.EarlyNotificationSent
&& DateTimeOffset.UtcNow && DateTimeOffset.UtcNow
>= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset) { >= scheduledEvent.ScheduledStartTime
- GuildSettings.EventEarlyNotificationOffset.Get(data.Settings)) {
var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct); var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct);
if (earlyResult.IsSuccess) if (earlyResult.IsSuccess)
storedEvent.EarlyNotificationSent = true; storedEvent.EarlyNotificationSent = true;
@ -203,7 +205,7 @@ public class GuildUpdateService : BackgroundService {
var result = scheduledEvent.Status switch { var result = scheduledEvent.Status switch {
GuildScheduledEventStatus.Scheduled => GuildScheduledEventStatus.Scheduled =>
await SendScheduledEventCreatedMessage(scheduledEvent, data.Configuration, ct), await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct),
GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed => GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed =>
await SendScheduledEventUpdatedMessage(scheduledEvent, data, false, ct), await SendScheduledEventUpdatedMessage(scheduledEvent, data, false, ct),
_ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))) _ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)))
@ -215,19 +217,17 @@ public class GuildUpdateService : BackgroundService {
} }
/// <summary> /// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventNotificationRole" /> if one is /// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole" /> if one is
/// set, /// set,
/// when a scheduled event is created /// when a scheduled event is created
/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set. /// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary> /// </summary>
/// <param name="scheduledEvent">The scheduled event that has just been created.</param> /// <param name="scheduledEvent">The scheduled event that has just been created.</param>
/// <param name="config">The configuration of the guild containing the scheduled event.</param> /// <param name="settings">The settings of the guild containing the scheduled event.</param>
/// <param name="ct">The cancellation token for this operation.</param> /// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A notification sending result which may or may not have succeeded.</returns> /// <returns>A notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventCreatedMessage( private async Task<Result> SendScheduledEventCreatedMessage(
IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) { IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) {
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
if (!scheduledEvent.CreatorID.IsDefined(out var creatorId)) if (!scheduledEvent.CreatorID.IsDefined(out var creatorId))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID))); return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID)));
@ -275,14 +275,13 @@ public class GuildUpdateService : BackgroundService {
.WithTitle(scheduledEvent.Name) .WithTitle(scheduledEvent.Name)
.WithDescription(embedDescription) .WithDescription(embedDescription)
.WithEventCover(scheduledEvent.ID, scheduledEvent.Image) .WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
.WithUserFooter(currentUser)
.WithCurrentTimestamp() .WithCurrentTimestamp()
.WithColour(ColorsList.White) .WithColour(ColorsList.White)
.Build(); .Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed); if (!embed.IsDefined(out var built)) return Result.FromError(embed);
var roleMention = config.EventNotificationRole is not 0 var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty()
? Mention.Role(config.EventNotificationRole.ToDiscordSnowflake()) ? Mention.Role(GuildSettings.EventNotificationRole.Get(settings))
: string.Empty; : string.Empty;
var button = new ButtonComponent( var button = new ButtonComponent(
@ -294,14 +293,14 @@ public class GuildUpdateService : BackgroundService {
); );
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
config.EventNotificationChannel.ToDiscordSnowflake(), roleMention, embeds: new[] { built }, GuildSettings.EventNotificationChannel.Get(settings), roleMention, embeds: new[] { built },
components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct); components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct);
} }
/// <summary> /// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventStartedReceivers" />s, /// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole"/> and event subscribers,
/// when a scheduled event is about to start, has started or completed /// when a scheduled event is about to start, has started or completed
/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set. /// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary> /// </summary>
/// <param name="scheduledEvent">The scheduled event that is about to start, has started or completed.</param> /// <param name="scheduledEvent">The scheduled event that is about to start, has started or completed.</param>
/// <param name="data">The data for the guild containing the scheduled event.</param> /// <param name="data">The data for the guild containing the scheduled event.</param>
@ -353,7 +352,7 @@ public class GuildUpdateService : BackgroundService {
} }
var contentResult = await _utility.GetEventNotificationMentions( var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Configuration, ct); scheduledEvent, data.Settings, ct);
if (!contentResult.IsDefined(out content)) if (!contentResult.IsDefined(out content))
return Result.FromError(contentResult); return Result.FromError(contentResult);
@ -383,7 +382,7 @@ public class GuildUpdateService : BackgroundService {
if (!result.IsDefined(out var built)) return Result.FromError(result); if (!result.IsDefined(out var built)) return Result.FromError(result);
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
data.Configuration.EventNotificationChannel.ToDiscordSnowflake(), GuildSettings.EventNotificationChannel.Get(data.Settings),
content ?? default(Optional<string>), embeds: new[] { built }, ct: ct); content ?? default(Optional<string>), embeds: new[] { built }, ct: ct);
} }
} }

View file

@ -1,4 +1,5 @@
using System.Text; using System.Text;
using System.Text.Json.Nodes;
using Boyfriend.Data; using Boyfriend.Data;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -103,38 +104,32 @@ public class UtilityService : IHostedService {
} }
/// <summary> /// <summary>
/// Gets the string mentioning all <see cref="GuildConfiguration.NotificationReceiver" />s related to a scheduled /// Gets the string mentioning the <see cref="GuildSettings.EventNotificationRole"/> and event subscribers related to a scheduled
/// event. /// event.
/// </summary> /// </summary>
/// <remarks>
/// If the guild configuration enables <see cref="GuildConfiguration.NotificationReceiver.Role" />, then the
/// <see cref="GuildConfiguration.EventNotificationRole" /> will also be mentioned.
/// </remarks>
/// <param name="scheduledEvent"> /// <param name="scheduledEvent">
/// The scheduled event whose subscribers will be mentioned if the guild configuration enables /// The scheduled event whose subscribers will be mentioned.
/// <see cref="GuildConfiguration.NotificationReceiver.Interested" />.
/// </param> /// </param>
/// <param name="config">The configuration of the guild containing the scheduled event</param> /// <param name="settings">The settings of the guild containing the scheduled event</param>
/// <param name="ct">The cancellation token for this operation.</param> /// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A result containing the string which may or may not have succeeded.</returns> /// <returns>A result containing the string which may or may not have succeeded.</returns>
public async Task<Result<string>> GetEventNotificationMentions( public async Task<Result<string>> GetEventNotificationMentions(
IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) { IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) {
var builder = new StringBuilder(); var builder = new StringBuilder();
var receivers = config.EventStartedReceivers; var role = GuildSettings.EventNotificationRole.Get(settings);
var role = config.EventNotificationRole.ToDiscordSnowflake();
var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync( var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync(
scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct); scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct);
if (!usersResult.IsDefined(out var users)) return Result<string>.FromError(usersResult); if (!usersResult.IsDefined(out var users)) return Result<string>.FromError(usersResult);
if (receivers.Contains(GuildConfiguration.NotificationReceiver.Role) && role.Value is not 0) if (role.Value is not 0)
builder.Append($"{Mention.Role(role)} "); builder.Append($"{Mention.Role(role)} ");
if (receivers.Contains(GuildConfiguration.NotificationReceiver.Interested))
builder = users.Where( builder = users.Where(
user => { user => {
if (!user.GuildMember.IsDefined(out var member)) return true; if (!user.GuildMember.IsDefined(out var member)) return true;
return !member.Roles.Contains(role); return !member.Roles.Contains(role);
}) })
.Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} ")); .Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} "));
return builder.ToString(); return builder.ToString();
} }
} }