forked from TeamInklings/Octobot
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:
parent
3eb17b96c5
commit
c6dd3727c3
39 changed files with 912 additions and 658 deletions
6
.github/workflows/resharper.yml
vendored
6
.github/workflows/resharper.yml
vendored
|
@ -4,10 +4,12 @@ concurrency:
|
|||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
inspect-code:
|
||||
|
|
|
@ -21,8 +21,12 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="DiffPlex" Version="1.7.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="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>
|
||||
<EmbeddedResource Update="locale\Messages.resx">
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
<picture>
|
||||
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png">
|
||||
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/95250141/206895340-3415d97d-91fd-4fb6-8c17-4e1bf340e1df.png">
|
||||
|
||||
<img alt="Boyfriend Logo" src="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png">
|
||||
|
||||
</picture>
|
||||
<p align="center">
|
||||
<img src="https://cdn.upload.systems/uploads/v40uV9K1.png" alt="Boyfriend logo" width="75%"/>
|
||||
</p>
|
||||
|
||||
![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)
|
||||
|
|
BIN
docs/assets/boyfriend.png
Normal file
BIN
docs/assets/boyfriend.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 224 KiB |
|
@ -56,23 +56,28 @@ public class Boyfriend {
|
|||
| GatewayIntents.GuildMembers
|
||||
| GatewayIntents.GuildScheduledEvents);
|
||||
services.Configure<CacheSettings>(
|
||||
settings => {
|
||||
settings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
|
||||
settings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
|
||||
settings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
|
||||
settings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7));
|
||||
cSettings => {
|
||||
cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
|
||||
cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
|
||||
cSettings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
|
||||
cSettings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7));
|
||||
});
|
||||
|
||||
services.AddTransient<IConfigurationBuilder, ConfigurationBuilder>()
|
||||
// Init
|
||||
.AddDiscordCaching()
|
||||
.AddDiscordCommands(true)
|
||||
.AddPreparationErrorEvent<ErrorLoggingPreparationErrorEvent>()
|
||||
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
|
||||
// Interactions
|
||||
.AddInteractivity()
|
||||
.AddInteractionGroup<InteractionResponders>()
|
||||
// Slash command event handlers
|
||||
.AddPreparationErrorEvent<ErrorLoggingPreparationErrorEvent>()
|
||||
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
|
||||
// Services
|
||||
.AddSingleton<GuildDataService>()
|
||||
.AddSingleton<UtilityService>()
|
||||
.AddHostedService<GuildUpdateService>()
|
||||
// Slash commands
|
||||
.AddCommandTree()
|
||||
.WithCommandGroup<AboutCommandGroup>()
|
||||
.WithCommandGroup<BanCommandGroup>()
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using Boyfriend.Data;
|
||||
using Boyfriend.Services;
|
||||
using JetBrains.Annotations;
|
||||
using Remora.Commands.Attributes;
|
||||
using Remora.Commands.Groups;
|
||||
using Remora.Discord.API.Abstractions.Rest;
|
||||
|
@ -10,14 +12,12 @@ using Remora.Discord.Extensions.Embeds;
|
|||
using Remora.Discord.Extensions.Formatting;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the command to show information about this bot: /about.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class AboutCommandGroup : CommandGroup {
|
||||
private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" };
|
||||
private readonly ICommandContext _context;
|
||||
|
@ -42,6 +42,7 @@ public class AboutCommandGroup : CommandGroup {
|
|||
/// </returns>
|
||||
[Command("about")]
|
||||
[Description("Shows Boyfriend's developers")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> SendAboutBotAsync() {
|
||||
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
|
||||
return Result.FromError(
|
||||
|
@ -51,8 +52,8 @@ public class AboutCommandGroup : CommandGroup {
|
|||
if (!currentUserResult.IsDefined(out var currentUser))
|
||||
return Result.FromError(currentUserResult);
|
||||
|
||||
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
|
||||
Messages.Culture = cfg.GetCulture();
|
||||
var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers));
|
||||
foreach (var dev in Developers)
|
||||
|
@ -65,8 +66,7 @@ public class AboutCommandGroup : CommandGroup {
|
|||
var embed = new EmbedBuilder().WithSmallTitle(Messages.AboutBot, currentUser)
|
||||
.WithDescription(builder.ToString())
|
||||
.WithColour(ColorsList.Cyan)
|
||||
.WithImageUrl(
|
||||
"https://media.discordapp.net/attachments/837385840946053181/1125009665592393738/boyfriend.png")
|
||||
.WithImageUrl("https://cdn.upload.systems/uploads/JFAaX5vr.png")
|
||||
.Build();
|
||||
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
|
||||
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using Boyfriend.Data;
|
||||
using Boyfriend.Services;
|
||||
using JetBrains.Annotations;
|
||||
using Remora.Commands.Attributes;
|
||||
using Remora.Commands.Groups;
|
||||
using Remora.Discord.API.Abstractions.Objects;
|
||||
using Remora.Discord.API.Abstractions.Rest;
|
||||
using Remora.Discord.API.Objects;
|
||||
using Remora.Discord.Commands.Attributes;
|
||||
using Remora.Discord.Commands.Conditions;
|
||||
using Remora.Discord.Commands.Contexts;
|
||||
using Remora.Discord.Commands.Feedback.Services;
|
||||
|
@ -13,14 +16,12 @@ using Remora.Discord.Extensions.Embeds;
|
|||
using Remora.Discord.Extensions.Formatting;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles commands related to ban management: /ban and /unban.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class BanCommandGroup : CommandGroup {
|
||||
private readonly IDiscordRestChannelAPI _channelApi;
|
||||
private readonly ICommandContext _context;
|
||||
|
@ -58,10 +59,13 @@ public class BanCommandGroup : CommandGroup {
|
|||
/// </returns>
|
||||
/// <seealso cref="UnbanUserAsync" />
|
||||
[Command("ban", "бан")]
|
||||
[DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[RequireContext(ChannelContext.Guild)]
|
||||
[RequireDiscordPermission(DiscordPermission.BanMembers)]
|
||||
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
|
||||
[Description("Ban user")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> BanUserAsync(
|
||||
[Description("User to ban")] IUser target,
|
||||
[Description("Ban reason")] string reason,
|
||||
|
@ -76,8 +80,8 @@ public class BanCommandGroup : CommandGroup {
|
|||
return Result.FromError(currentUserResult);
|
||||
|
||||
var data = await _dataService.GetData(guildId.Value, CancellationToken);
|
||||
var cfg = data.Configuration;
|
||||
Messages.Culture = data.Culture;
|
||||
var cfg = data.Settings;
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken);
|
||||
if (existingBanResult.IsDefined()) {
|
||||
|
@ -145,8 +149,10 @@ public class BanCommandGroup : CommandGroup {
|
|||
string.Format(Messages.UserBanned, target.GetTag()), target)
|
||||
.WithColour(ColorsList.Green).Build();
|
||||
|
||||
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|
||||
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
|
||||
if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|
||||
&& GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
|
||||
var logEmbed = new EmbedBuilder().WithSmallTitle(
|
||||
string.Format(Messages.UserBanned, target.GetTag()), target)
|
||||
.WithDescription(description)
|
||||
|
@ -160,14 +166,14 @@ public class BanCommandGroup : CommandGroup {
|
|||
|
||||
var builtArray = new[] { logBuilt };
|
||||
// Not awaiting to reduce response time
|
||||
if (cfg.PublicFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
|
||||
&& cfg.PrivateFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
}
|
||||
}
|
||||
|
@ -193,10 +199,13 @@ public class BanCommandGroup : CommandGroup {
|
|||
/// <seealso cref="BanUserAsync" />
|
||||
/// <seealso cref="GuildUpdateService.TickGuildAsync"/>
|
||||
[Command("unban")]
|
||||
[DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[RequireContext(ChannelContext.Guild)]
|
||||
[RequireDiscordPermission(DiscordPermission.BanMembers)]
|
||||
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
|
||||
[Description("Unban user")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> UnbanUserAsync(
|
||||
[Description("User to unban")] IUser target,
|
||||
[Description("Unban reason")] string reason) {
|
||||
|
@ -209,8 +218,8 @@ public class BanCommandGroup : CommandGroup {
|
|||
if (!currentUserResult.IsDefined(out var currentUser))
|
||||
return Result.FromError(currentUserResult);
|
||||
|
||||
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
|
||||
Messages.Culture = cfg.GetCulture();
|
||||
var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken);
|
||||
if (!existingBanResult.IsDefined()) {
|
||||
|
@ -238,8 +247,10 @@ public class BanCommandGroup : CommandGroup {
|
|||
string.Format(Messages.UserUnbanned, target.GetTag()), target)
|
||||
.WithColour(ColorsList.Green).Build();
|
||||
|
||||
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|
||||
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
|
||||
if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|
||||
&& GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
|
||||
var logEmbed = new EmbedBuilder().WithSmallTitle(
|
||||
string.Format(Messages.UserUnbanned, target.GetTag()), target)
|
||||
.WithDescription(string.Format(Messages.DescriptionActionReason, reason))
|
||||
|
@ -254,14 +265,14 @@ public class BanCommandGroup : CommandGroup {
|
|||
var builtArray = new[] { logBuilt };
|
||||
|
||||
// Not awaiting to reduce response time
|
||||
if (cfg.PublicFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
|
||||
&& cfg.PrivateFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using Boyfriend.Data;
|
||||
using Boyfriend.Services;
|
||||
using JetBrains.Annotations;
|
||||
using Remora.Commands.Attributes;
|
||||
using Remora.Commands.Groups;
|
||||
using Remora.Discord.API.Abstractions.Objects;
|
||||
|
@ -14,14 +16,12 @@ using Remora.Discord.Extensions.Formatting;
|
|||
using Remora.Rest.Core;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the command to clear messages in a channel: /clear.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class ClearCommandGroup : CommandGroup {
|
||||
private readonly IDiscordRestChannelAPI _channelApi;
|
||||
private readonly ICommandContext _context;
|
||||
|
@ -48,10 +48,13 @@ public class ClearCommandGroup : CommandGroup {
|
|||
/// were cleared and vice-versa.
|
||||
/// </returns>
|
||||
[Command("clear", "очистить")]
|
||||
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[RequireContext(ChannelContext.Guild)]
|
||||
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
|
||||
[RequireBotDiscordPermissions(DiscordPermission.ManageMessages)]
|
||||
[Description("Remove multiple messages")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> ClearMessagesAsync(
|
||||
[Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)]
|
||||
int amount) {
|
||||
|
@ -64,8 +67,8 @@ public class ClearCommandGroup : CommandGroup {
|
|||
if (!messagesResult.IsDefined(out var messages))
|
||||
return Result.FromError(messagesResult);
|
||||
|
||||
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
|
||||
Messages.Culture = cfg.GetCulture();
|
||||
var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
var idList = new List<Snowflake>(messages.Count);
|
||||
var builder = new StringBuilder().AppendLine(Mention.Channel(channelId.Value)).AppendLine();
|
||||
|
@ -93,7 +96,8 @@ public class ClearCommandGroup : CommandGroup {
|
|||
return Result.FromError(currentUserResult);
|
||||
|
||||
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)
|
||||
.WithDescription(description)
|
||||
.WithActionFooter(user)
|
||||
|
@ -105,9 +109,9 @@ public class ClearCommandGroup : CommandGroup {
|
|||
return Result.FromError(logEmbed);
|
||||
|
||||
// Not awaiting to reduce response time
|
||||
if (cfg.PrivateFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { logBuilt },
|
||||
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { logBuilt },
|
||||
ct: CancellationToken);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
using JetBrains.Annotations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Remora.Discord.Commands.Contexts;
|
||||
using Remora.Discord.Commands.Extensions;
|
||||
using Remora.Discord.Commands.Services;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles error logging for slash commands that couldn't be successfully prepared.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent {
|
||||
private readonly ILogger<ErrorLoggingPreparationErrorEvent> _logger;
|
||||
|
||||
|
@ -27,8 +28,11 @@ public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent {
|
|||
/// <returns>A result which has succeeded.</returns>
|
||||
public Task<Result> PreparationFailed(
|
||||
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);
|
||||
if (preparationResult.Error is ExceptionError exerr)
|
||||
_logger.LogError(exerr.Exception, "An exception has been thrown");
|
||||
}
|
||||
|
||||
return Task.FromResult(Result.FromSuccess());
|
||||
}
|
||||
|
@ -37,6 +41,7 @@ public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent {
|
|||
/// <summary>
|
||||
/// Handles error logging for slash command groups.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent {
|
||||
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
|
||||
|
||||
|
@ -54,8 +59,11 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent {
|
|||
/// <returns>A result which has succeeded.</returns>
|
||||
public Task<Result> AfterExecutionAsync(
|
||||
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);
|
||||
if (commandResult.Error is ExceptionError exerr)
|
||||
_logger.LogError(exerr.Exception, "An exception has been thrown");
|
||||
}
|
||||
|
||||
return Task.FromResult(Result.FromSuccess());
|
||||
}
|
||||
|
|
|
@ -1,24 +1,25 @@
|
|||
using System.ComponentModel;
|
||||
using Boyfriend.Data;
|
||||
using Boyfriend.Services;
|
||||
using JetBrains.Annotations;
|
||||
using Remora.Commands.Attributes;
|
||||
using Remora.Commands.Groups;
|
||||
using Remora.Discord.API.Abstractions.Objects;
|
||||
using Remora.Discord.API.Abstractions.Rest;
|
||||
using Remora.Discord.API.Objects;
|
||||
using Remora.Discord.Commands.Attributes;
|
||||
using Remora.Discord.Commands.Conditions;
|
||||
using Remora.Discord.Commands.Contexts;
|
||||
using Remora.Discord.Commands.Feedback.Services;
|
||||
using Remora.Discord.Extensions.Embeds;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the command to kick members of a guild: /kick.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class KickCommandGroup : CommandGroup {
|
||||
private readonly IDiscordRestChannelAPI _channelApi;
|
||||
private readonly ICommandContext _context;
|
||||
|
@ -54,10 +55,13 @@ public class KickCommandGroup : CommandGroup {
|
|||
/// was kicked and vice-versa.
|
||||
/// </returns>
|
||||
[Command("kick", "кик")]
|
||||
[DiscordDefaultMemberPermissions(DiscordPermission.KickMembers)]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[RequireContext(ChannelContext.Guild)]
|
||||
[RequireDiscordPermission(DiscordPermission.KickMembers)]
|
||||
[RequireBotDiscordPermissions(DiscordPermission.KickMembers)]
|
||||
[Description("Kick member")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> KickUserAsync(
|
||||
[Description("Member to kick")] IUser target,
|
||||
[Description("Kick reason")] string reason) {
|
||||
|
@ -71,8 +75,8 @@ public class KickCommandGroup : CommandGroup {
|
|||
return Result.FromError(currentUserResult);
|
||||
|
||||
var data = await _dataService.GetData(guildId.Value, CancellationToken);
|
||||
var cfg = data.Configuration;
|
||||
Messages.Culture = cfg.GetCulture();
|
||||
var cfg = data.Settings;
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken);
|
||||
if (!memberResult.IsSuccess) {
|
||||
|
@ -129,8 +133,10 @@ public class KickCommandGroup : CommandGroup {
|
|||
string.Format(Messages.UserKicked, target.GetTag()), target)
|
||||
.WithColour(ColorsList.Green).Build();
|
||||
|
||||
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|
||||
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
|
||||
if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|
||||
&& GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
|
||||
var logEmbed = new EmbedBuilder().WithSmallTitle(
|
||||
string.Format(Messages.UserKicked, target.GetTag()), target)
|
||||
.WithDescription(string.Format(Messages.DescriptionActionReason, reason))
|
||||
|
@ -144,14 +150,14 @@ public class KickCommandGroup : CommandGroup {
|
|||
|
||||
var builtArray = new[] { logBuilt };
|
||||
// Not awaiting to reduce response time
|
||||
if (cfg.PublicFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
|
||||
&& cfg.PrivateFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using Boyfriend.Data;
|
||||
using Boyfriend.Services;
|
||||
using JetBrains.Annotations;
|
||||
using Remora.Commands.Attributes;
|
||||
using Remora.Commands.Groups;
|
||||
using Remora.Discord.API.Abstractions.Objects;
|
||||
using Remora.Discord.API.Abstractions.Rest;
|
||||
using Remora.Discord.API.Objects;
|
||||
using Remora.Discord.Commands.Attributes;
|
||||
using Remora.Discord.Commands.Conditions;
|
||||
using Remora.Discord.Commands.Contexts;
|
||||
using Remora.Discord.Commands.Feedback.Services;
|
||||
|
@ -13,14 +16,12 @@ using Remora.Discord.Extensions.Embeds;
|
|||
using Remora.Discord.Extensions.Formatting;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles commands related to mute management: /mute and /unmute.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class MuteCommandGroup : CommandGroup {
|
||||
private readonly IDiscordRestChannelAPI _channelApi;
|
||||
private readonly ICommandContext _context;
|
||||
|
@ -58,10 +59,13 @@ public class MuteCommandGroup : CommandGroup {
|
|||
/// </returns>
|
||||
/// <seealso cref="UnmuteUserAsync" />
|
||||
[Command("mute", "мут")]
|
||||
[DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[RequireContext(ChannelContext.Guild)]
|
||||
[RequireDiscordPermission(DiscordPermission.ModerateMembers)]
|
||||
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
|
||||
[Description("Mute member")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> MuteUserAsync(
|
||||
[Description("Member to mute")] IUser target,
|
||||
[Description("Mute reason")] string reason,
|
||||
|
@ -93,8 +97,8 @@ public class MuteCommandGroup : CommandGroup {
|
|||
return Result.FromError(interactionResult);
|
||||
|
||||
var data = await _dataService.GetData(guildId.Value, CancellationToken);
|
||||
var cfg = data.Configuration;
|
||||
Messages.Culture = data.Culture;
|
||||
var cfg = data.Settings;
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
Result<Embed> responseEmbed;
|
||||
if (interactionResult.Entity is not null) {
|
||||
|
@ -116,8 +120,10 @@ public class MuteCommandGroup : CommandGroup {
|
|||
string.Format(Messages.UserMuted, target.GetTag()), target)
|
||||
.WithColour(ColorsList.Green).Build();
|
||||
|
||||
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|
||||
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
|
||||
if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|
||||
&& 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))
|
||||
.Append(
|
||||
string.Format(
|
||||
|
@ -136,14 +142,14 @@ public class MuteCommandGroup : CommandGroup {
|
|||
|
||||
var builtArray = new[] { logBuilt };
|
||||
// Not awaiting to reduce response time
|
||||
if (cfg.PublicFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
|
||||
&& cfg.PrivateFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
}
|
||||
}
|
||||
|
@ -169,10 +175,13 @@ public class MuteCommandGroup : CommandGroup {
|
|||
/// <seealso cref="MuteUserAsync" />
|
||||
/// <seealso cref="GuildUpdateService.TickGuildAsync"/>
|
||||
[Command("unmute", "размут")]
|
||||
[DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[RequireContext(ChannelContext.Guild)]
|
||||
[RequireDiscordPermission(DiscordPermission.ModerateMembers)]
|
||||
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
|
||||
[Description("Unmute member")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> UnmuteUserAsync(
|
||||
[Description("Member to unmute")] IUser target,
|
||||
[Description("Unmute reason")] string reason) {
|
||||
|
@ -185,8 +194,8 @@ public class MuteCommandGroup : CommandGroup {
|
|||
if (!currentUserResult.IsDefined(out var currentUser))
|
||||
return Result.FromError(currentUserResult);
|
||||
|
||||
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
|
||||
Messages.Culture = cfg.GetCulture();
|
||||
var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken);
|
||||
if (!memberResult.IsSuccess) {
|
||||
|
@ -220,8 +229,10 @@ public class MuteCommandGroup : CommandGroup {
|
|||
string.Format(Messages.UserUnmuted, target.GetTag()), target)
|
||||
.WithColour(ColorsList.Green).Build();
|
||||
|
||||
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|
||||
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
|
||||
if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|
||||
&& GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
|| (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) {
|
||||
var logEmbed = new EmbedBuilder().WithSmallTitle(
|
||||
string.Format(Messages.UserUnmuted, target.GetTag()), target)
|
||||
.WithDescription(string.Format(Messages.DescriptionActionReason, reason))
|
||||
|
@ -236,14 +247,14 @@ public class MuteCommandGroup : CommandGroup {
|
|||
var builtArray = new[] { logBuilt };
|
||||
|
||||
// Not awaiting to reduce response time
|
||||
if (cfg.PublicFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
|
||||
&& cfg.PrivateFeedbackChannel != channelId.Value)
|
||||
if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg)
|
||||
&& GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)
|
||||
_ = _channelApi.CreateMessageAsync(
|
||||
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
|
||||
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray,
|
||||
ct: CancellationToken);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
using System.ComponentModel;
|
||||
using Boyfriend.Data;
|
||||
using Boyfriend.Services;
|
||||
using JetBrains.Annotations;
|
||||
using Remora.Commands.Attributes;
|
||||
using Remora.Commands.Groups;
|
||||
using Remora.Discord.API.Abstractions.Rest;
|
||||
|
@ -9,14 +11,12 @@ using Remora.Discord.Extensions.Embeds;
|
|||
using Remora.Discord.Gateway;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class PingCommandGroup : CommandGroup {
|
||||
private readonly IDiscordRestChannelAPI _channelApi;
|
||||
private readonly DiscordGatewayClient _client;
|
||||
|
@ -44,6 +44,7 @@ public class PingCommandGroup : CommandGroup {
|
|||
/// </returns>
|
||||
[Command("ping", "пинг")]
|
||||
[Description("Get bot latency")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> SendPingAsync() {
|
||||
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _))
|
||||
return Result.FromError(
|
||||
|
@ -53,8 +54,8 @@ public class PingCommandGroup : CommandGroup {
|
|||
if (!currentUserResult.IsDefined(out var currentUser))
|
||||
return Result.FromError(currentUserResult);
|
||||
|
||||
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
|
||||
Messages.Culture = cfg.GetCulture();
|
||||
var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
var latency = _client.Latency.TotalMilliseconds;
|
||||
if (latency is 0) {
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
using System.ComponentModel;
|
||||
using Boyfriend.Data;
|
||||
using Boyfriend.Services;
|
||||
using JetBrains.Annotations;
|
||||
using Remora.Commands.Attributes;
|
||||
using Remora.Commands.Groups;
|
||||
using Remora.Discord.API.Abstractions.Rest;
|
||||
using Remora.Discord.Commands.Attributes;
|
||||
using Remora.Discord.Commands.Contexts;
|
||||
using Remora.Discord.Commands.Feedback.Services;
|
||||
using Remora.Discord.Extensions.Embeds;
|
||||
using Remora.Discord.Extensions.Formatting;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the command to manage reminders: /remind
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class RemindCommandGroup : CommandGroup {
|
||||
private readonly ICommandContext _context;
|
||||
private readonly GuildDataService _dataService;
|
||||
|
@ -40,7 +40,9 @@ public class RemindCommandGroup : CommandGroup {
|
|||
/// <param name="message">The text of the reminder.</param>
|
||||
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
|
||||
[Command("remind")]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[Description("Create a reminder")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> AddReminderAsync(
|
||||
[Description("After what period of time mention the reminder")]
|
||||
TimeSpan @in,
|
||||
|
@ -57,8 +59,8 @@ public class RemindCommandGroup : CommandGroup {
|
|||
|
||||
(await _dataService.GetMemberData(guildId.Value, userId.Value, CancellationToken)).Reminders.Add(
|
||||
new Reminder {
|
||||
RemindAt = remindAt,
|
||||
Channel = channelId.Value,
|
||||
At = remindAt,
|
||||
Channel = channelId.Value.Value,
|
||||
Text = message
|
||||
});
|
||||
|
||||
|
|
|
@ -1,26 +1,44 @@
|
|||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Boyfriend.Data;
|
||||
using Boyfriend.Data.Options;
|
||||
using Boyfriend.Services;
|
||||
using JetBrains.Annotations;
|
||||
using Remora.Commands.Attributes;
|
||||
using Remora.Commands.Groups;
|
||||
using Remora.Discord.API.Abstractions.Objects;
|
||||
using Remora.Discord.API.Abstractions.Rest;
|
||||
using Remora.Discord.Commands.Attributes;
|
||||
using Remora.Discord.Commands.Conditions;
|
||||
using Remora.Discord.Commands.Contexts;
|
||||
using Remora.Discord.Commands.Feedback.Services;
|
||||
using Remora.Discord.Extensions.Embeds;
|
||||
using Remora.Discord.Extensions.Formatting;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the commands to list and modify per-guild settings: /settings and /settings list.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
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 GuildDataService _dataService;
|
||||
private readonly FeedbackService _feedbackService;
|
||||
|
@ -36,13 +54,18 @@ public class SettingsCommandGroup : CommandGroup {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// A slash command that lists current per-guild settings.
|
||||
/// A slash command that lists current per-guild GuildSettings.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A feedback sending result which may or may not have succeeded.
|
||||
/// </returns>
|
||||
[Command("settingslist")]
|
||||
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[RequireContext(ChannelContext.Guild)]
|
||||
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
|
||||
[Description("Shows settings list for this server")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> ListSettingsAsync() {
|
||||
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
|
||||
return Result.FromError(
|
||||
|
@ -52,19 +75,15 @@ public class SettingsCommandGroup : CommandGroup {
|
|||
if (!currentUserResult.IsDefined(out var currentUser))
|
||||
return Result.FromError(currentUserResult);
|
||||
|
||||
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
|
||||
Messages.Culture = cfg.GetCulture();
|
||||
var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (var setting in typeof(GuildConfiguration).GetProperties()) {
|
||||
builder.Append(Markdown.InlineCode(setting.Name))
|
||||
foreach (var option in AllOptions) {
|
||||
builder.Append(Markdown.InlineCode(option.Name))
|
||||
.Append(": ");
|
||||
var something = setting.GetValue(cfg);
|
||||
if (something!.GetType() == typeof(List<GuildConfiguration.NotificationReceiver>)) {
|
||||
var list = (something as List<GuildConfiguration.NotificationReceiver>);
|
||||
builder.AppendLine(string.Join(", ", list!.Select(v => Markdown.InlineCode(v.ToString()))));
|
||||
} else { builder.AppendLine(Markdown.InlineCode(something.ToString()!)); }
|
||||
builder.AppendLine(option.Display(cfg));
|
||||
}
|
||||
|
||||
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, currentUser)
|
||||
|
@ -77,13 +96,18 @@ public class SettingsCommandGroup : CommandGroup {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// A slash command that modifies per-guild settings.
|
||||
/// A slash command that modifies per-guild GuildSettings.
|
||||
/// </summary>
|
||||
/// <param name="setting">The setting to modify.</param>
|
||||
/// <param name="value">The new value of the setting.</param>
|
||||
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
|
||||
[Command("settings")]
|
||||
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
|
||||
[DiscordDefaultDMPermission(false)]
|
||||
[RequireContext(ChannelContext.Guild)]
|
||||
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
|
||||
[Description("Change settings for this server")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> EditSettingsAsync(
|
||||
[Description("The setting whose value you want to change")]
|
||||
string setting,
|
||||
|
@ -96,40 +120,16 @@ public class SettingsCommandGroup : CommandGroup {
|
|||
if (!currentUserResult.IsDefined(out var currentUser))
|
||||
return Result.FromError(currentUserResult);
|
||||
|
||||
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
|
||||
Messages.Culture = cfg.GetCulture();
|
||||
var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken);
|
||||
Messages.Culture = GuildSettings.Language.Get(cfg);
|
||||
|
||||
PropertyInfo? property = null;
|
||||
var option = AllOptions.Single(
|
||||
o => string.Equals(setting, o.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
try {
|
||||
foreach (var prop in typeof(GuildConfiguration).GetProperties())
|
||||
if (string.Equals(setting, prop.Name, StringComparison.CurrentCultureIgnoreCase))
|
||||
property = prop;
|
||||
if (property == null || !property.CanWrite)
|
||||
throw new ApplicationException(Messages.SettingDoesntExist);
|
||||
var type = property.PropertyType;
|
||||
|
||||
if (value is "reset" or "default") { property.SetValue(cfg, null); } else if (type == typeof(string)) {
|
||||
if (setting == "language" && value is not ("ru" or "en" or "mctaylors-ru"))
|
||||
throw new ApplicationException(Messages.LanguageNotSupported);
|
||||
property.SetValue(cfg, value);
|
||||
} else {
|
||||
try {
|
||||
if (type == typeof(bool))
|
||||
property.SetValue(cfg, Convert.ToBoolean(value));
|
||||
|
||||
if (type == typeof(ulong)) {
|
||||
var id = Convert.ToUInt64(value);
|
||||
|
||||
property.SetValue(cfg, id);
|
||||
}
|
||||
} catch (Exception e) when (e is FormatException or OverflowException) {
|
||||
throw new ApplicationException(Messages.InvalidSettingValue);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
var setResult = option.Set(cfg, value);
|
||||
if (!setResult.IsSuccess) {
|
||||
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, currentUser)
|
||||
.WithDescription(e.Message)
|
||||
.WithDescription(setResult.Error.Message)
|
||||
.WithColour(ColorsList.Red)
|
||||
.Build();
|
||||
if (!failedEmbed.IsDefined(out var failedBuilt)) return Result.FromError(failedEmbed);
|
||||
|
@ -139,9 +139,9 @@ public class SettingsCommandGroup : CommandGroup {
|
|||
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.Append(Markdown.InlineCode(setting))
|
||||
builder.Append(Markdown.InlineCode(option.Name))
|
||||
.Append($" {Messages.SettingIsNow} ")
|
||||
.Append(Markdown.InlineCode(value));
|
||||
.Append(option.Display(cfg));
|
||||
|
||||
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingSuccessfullyChanged, currentUser)
|
||||
.WithDescription(builder.ToString())
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Remora.Rest.Core;
|
||||
|
||||
namespace Boyfriend.Data;
|
||||
|
@ -8,29 +8,26 @@ namespace Boyfriend.Data;
|
|||
/// </summary>
|
||||
/// <remarks>This information is stored on disk as a JSON file.</remarks>
|
||||
public class GuildData {
|
||||
public readonly GuildConfiguration Configuration;
|
||||
public readonly string ConfigurationPath;
|
||||
|
||||
public readonly Dictionary<ulong, MemberData> MemberData;
|
||||
public readonly string MemberDataPath;
|
||||
|
||||
public readonly Dictionary<ulong, ScheduledEventData> ScheduledEvents;
|
||||
public readonly string ScheduledEventsPath;
|
||||
public readonly JsonNode Settings;
|
||||
public readonly string SettingsPath;
|
||||
|
||||
public GuildData(
|
||||
GuildConfiguration configuration, string configurationPath,
|
||||
JsonNode settings, string settingsPath,
|
||||
Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
|
||||
Dictionary<ulong, MemberData> memberData, string memberDataPath) {
|
||||
Configuration = configuration;
|
||||
ConfigurationPath = configurationPath;
|
||||
Settings = settings;
|
||||
SettingsPath = settingsPath;
|
||||
ScheduledEvents = scheduledEvents;
|
||||
ScheduledEventsPath = scheduledEventsPath;
|
||||
MemberData = memberData;
|
||||
MemberDataPath = memberDataPath;
|
||||
}
|
||||
|
||||
public CultureInfo Culture => Configuration.GetCulture();
|
||||
|
||||
public MemberData GetMemberData(Snowflake userId) {
|
||||
if (MemberData.TryGetValue(userId.Value, out var existing)) return existing;
|
||||
|
||||
|
|
63
src/Data/GuildSettings.cs
Normal file
63
src/Data/GuildSettings.cs
Normal 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);
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
using Remora.Rest.Core;
|
||||
|
||||
namespace Boyfriend.Data;
|
||||
|
||||
/// <summary>
|
||||
|
@ -13,6 +11,6 @@ public class MemberData {
|
|||
|
||||
public ulong Id { get; }
|
||||
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();
|
||||
}
|
||||
|
|
34
src/Data/Options/BoolOption.cs
Normal file
34
src/Data/Options/BoolOption.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
10
src/Data/Options/IOption.cs
Normal file
10
src/Data/Options/IOption.cs
Normal 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);
|
||||
}
|
35
src/Data/Options/LanguageOption.cs
Normal file
35
src/Data/Options/LanguageOption.cs
Normal 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());
|
||||
}
|
||||
}
|
46
src/Data/Options/Option.cs
Normal file
46
src/Data/Options/Option.cs
Normal 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;
|
||||
}
|
||||
}
|
27
src/Data/Options/SnowflakeOption.cs
Normal file
27
src/Data/Options/SnowflakeOption.cs
Normal 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();
|
||||
}
|
||||
}
|
28
src/Data/Options/TimeSpanOption.cs
Normal file
28
src/Data/Options/TimeSpanOption.cs
Normal 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();
|
||||
}
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
using Remora.Rest.Core;
|
||||
|
||||
namespace Boyfriend.Data;
|
||||
|
||||
public struct Reminder {
|
||||
public DateTimeOffset RemindAt;
|
||||
public DateTimeOffset At;
|
||||
public string Text;
|
||||
public Snowflake Channel;
|
||||
public ulong Channel;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -170,7 +170,7 @@ public static class Extensions {
|
|||
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);
|
||||
}
|
||||
|
||||
|
@ -190,4 +190,8 @@ public static class Extensions {
|
|||
&& context.TryGetChannelID(out channelId)
|
||||
&& context.TryGetUserID(out userId);
|
||||
}
|
||||
|
||||
public static bool Empty(this Snowflake snowflake) {
|
||||
return snowflake.Value is 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
using JetBrains.Annotations;
|
||||
using Remora.Discord.API.Abstractions.Objects;
|
||||
using Remora.Discord.Commands.Feedback.Messages;
|
||||
using Remora.Discord.Commands.Feedback.Services;
|
||||
using Remora.Discord.Interactivity;
|
||||
using Remora.Results;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
|
||||
namespace Boyfriend;
|
||||
|
||||
/// <summary>
|
||||
/// Handles responding to various interactions.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public class InteractionResponders : InteractionGroup {
|
||||
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>
|
||||
/// <returns>An ephemeral feedback sending result which may or may not have succeeded.</returns>
|
||||
[Button("scheduled-event-details")]
|
||||
[UsedImplicitly]
|
||||
public async Task<Result> OnStatefulButtonClicked(string? state = null) {
|
||||
if (state is null) return Result.FromError(new ArgumentNullError(nameof(state)));
|
||||
|
||||
|
|
|
@ -8,9 +8,6 @@
|
|||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace Boyfriend {
|
||||
using System;
|
||||
|
||||
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
63
src/Responders/GuildLoadedResponder.cs
Normal file
63
src/Responders/GuildLoadedResponder.cs
Normal 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);
|
||||
}
|
||||
}
|
65
src/Responders/GuildMemberJoinedResponder.cs
Normal file
65
src/Responders/GuildMemberJoinedResponder.cs
Normal 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);
|
||||
}
|
||||
}
|
26
src/Responders/GuildMemberRolesUpdatedResponder.cs
Normal file
26
src/Responders/GuildMemberRolesUpdatedResponder.cs
Normal 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();
|
||||
}
|
||||
}
|
78
src/Responders/MessageDeletedResponder.cs
Normal file
78
src/Responders/MessageDeletedResponder.cs
Normal 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);
|
||||
}
|
||||
}
|
89
src/Responders/MessageEditedResponder.cs
Normal file
89
src/Responders/MessageEditedResponder.cs
Normal 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);
|
||||
}
|
||||
}
|
34
src/Responders/MessageReceivedResponder.cs
Normal file
34
src/Responders/MessageReceivedResponder.cs
Normal 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());
|
||||
}
|
||||
}
|
45
src/Responders/ScheduledEventCancelledResponder.cs
Normal file
45
src/Responders/ScheduledEventCancelledResponder.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Boyfriend.Data;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Remora.Discord.API.Abstractions.Rest;
|
||||
|
@ -36,8 +37,8 @@ public class GuildDataService : IHostedService {
|
|||
private async Task SaveAsync(CancellationToken ct) {
|
||||
var tasks = new List<Task>();
|
||||
foreach (var data in _datas.Values) {
|
||||
await using var configStream = File.OpenWrite(data.ConfigurationPath);
|
||||
tasks.Add(JsonSerializer.SerializeAsync(configStream, data.Configuration, cancellationToken: ct));
|
||||
await using var settingsStream = File.OpenWrite(data.SettingsPath);
|
||||
tasks.Add(JsonSerializer.SerializeAsync(settingsStream, data.Settings, cancellationToken: ct));
|
||||
|
||||
await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath);
|
||||
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) {
|
||||
var idString = $"{guildId}";
|
||||
var memberDataPath = $"{guildId}/MemberData";
|
||||
var configurationPath = $"{guildId}/Configuration.json";
|
||||
var settingsPath = $"{guildId}/Settings.json";
|
||||
var scheduledEventsPath = $"{guildId}/ScheduledEvents.json";
|
||||
if (!Directory.Exists(idString)) Directory.CreateDirectory(idString);
|
||||
if (!Directory.Exists(memberDataPath)) Directory.CreateDirectory(memberDataPath);
|
||||
if (!File.Exists(configurationPath)) await File.WriteAllTextAsync(configurationPath, "{}", ct);
|
||||
if (!File.Exists(settingsPath)) await File.WriteAllTextAsync(settingsPath, "{}", ct);
|
||||
if (!File.Exists(scheduledEventsPath)) await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
|
||||
|
||||
await using var configurationStream = File.OpenRead(configurationPath);
|
||||
var configuration
|
||||
= JsonSerializer.DeserializeAsync<GuildConfiguration>(
|
||||
configurationStream, cancellationToken: ct);
|
||||
await using var settingsStream = File.OpenRead(settingsPath);
|
||||
var jsonSettings
|
||||
= JsonNode.Parse(settingsStream);
|
||||
|
||||
await using var eventsStream = File.OpenRead(scheduledEventsPath);
|
||||
var events
|
||||
|
@ -80,23 +80,23 @@ public class GuildDataService : IHostedService {
|
|||
await using var dataStream = File.OpenRead(dataPath);
|
||||
var data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
|
||||
if (data is null) continue;
|
||||
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToDiscordSnowflake(), ct);
|
||||
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToSnowflake(), ct);
|
||||
if (memberResult.IsSuccess)
|
||||
data.Roles = memberResult.Entity.Roles.ToList();
|
||||
data.Roles = memberResult.Entity.Roles.ToList().ConvertAll(r => r.Value);
|
||||
|
||||
memberData.Add(data.Id, data);
|
||||
}
|
||||
|
||||
var finalData = new GuildData(
|
||||
await configuration ?? new GuildConfiguration(), configurationPath,
|
||||
jsonSettings ?? new JsonObject(), settingsPath,
|
||||
await events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
|
||||
memberData, memberDataPath);
|
||||
while (!_datas.ContainsKey(guildId)) _datas.TryAdd(guildId, finalData);
|
||||
return finalData;
|
||||
}
|
||||
|
||||
public async Task<GuildConfiguration> GetConfiguration(Snowflake guildId, CancellationToken ct = default) {
|
||||
return (await GetData(guildId, ct)).Configuration;
|
||||
public async Task<JsonNode> GetSettings(Snowflake guildId, CancellationToken ct = default) {
|
||||
return (await GetData(guildId, ct)).Settings;
|
||||
}
|
||||
|
||||
public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using Boyfriend.Data;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
@ -94,9 +95,9 @@ public class GuildUpdateService : BackgroundService {
|
|||
/// This method does the following:
|
||||
/// <list type="bullet">
|
||||
/// <item>Automatically unbans users once their ban period has expired.</item>
|
||||
/// <item>Automatically grants members the guild's <see cref="GuildConfiguration.DefaultRole"/> if one is set.</item>
|
||||
/// <item>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>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 completion notifications.</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>
|
||||
private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) {
|
||||
var data = await _dataService.GetData(guildId, ct);
|
||||
Messages.Culture = data.Culture;
|
||||
var defaultRoleSnowflake = data.Configuration.DefaultRole.ToDiscordSnowflake();
|
||||
Messages.Culture = GuildSettings.Language.Get(data.Settings);
|
||||
var defaultRole = GuildSettings.DefaultRole.Get(data.Settings);
|
||||
|
||||
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(
|
||||
guildId, userId, defaultRoleSnowflake, ct: ct);
|
||||
guildId, userId, defaultRole, ct: ct);
|
||||
|
||||
if (DateTimeOffset.UtcNow > memberData.BannedUntil) {
|
||||
var unbanResult = await _guildApi.RemoveGuildBanAsync(
|
||||
|
@ -139,7 +140,7 @@ public class GuildUpdateService : BackgroundService {
|
|||
|
||||
for (var i = memberData.Reminders.Count - 1; i >= 0; i--) {
|
||||
var reminder = memberData.Reminders[i];
|
||||
if (DateTimeOffset.UtcNow < reminder.RemindAt) continue;
|
||||
if (DateTimeOffset.UtcNow < reminder.At) continue;
|
||||
|
||||
var embed = new EmbedBuilder().WithSmallTitle(
|
||||
string.Format(Messages.Reminder, user.GetTag()), user)
|
||||
|
@ -151,7 +152,7 @@ public class GuildUpdateService : BackgroundService {
|
|||
if (!embed.IsDefined(out var built)) continue;
|
||||
|
||||
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)
|
||||
_logger.LogWarning(
|
||||
"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);
|
||||
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) {
|
||||
if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) {
|
||||
|
@ -172,7 +173,7 @@ public class GuildUpdateService : BackgroundService {
|
|||
var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value];
|
||||
if (storedEvent.Status == scheduledEvent.Status) {
|
||||
if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) {
|
||||
if (data.Configuration.AutoStartEvents
|
||||
if (GuildSettings.AutoStartEvents.Get(data.Settings)
|
||||
&& scheduledEvent.Status is not GuildScheduledEventStatus.Active) {
|
||||
var startResult = await _eventApi.ModifyGuildScheduledEventAsync(
|
||||
guildId, scheduledEvent.ID,
|
||||
|
@ -182,10 +183,11 @@ public class GuildUpdateService : BackgroundService {
|
|||
"Error in automatic scheduled event start request.\n{ErrorMessage}",
|
||||
startResult.Error.Message);
|
||||
}
|
||||
} else if (data.Configuration.EventEarlyNotificationOffset != TimeSpan.Zero
|
||||
} else if (GuildSettings.EventEarlyNotificationOffset.Get(data.Settings) != TimeSpan.Zero
|
||||
&& !storedEvent.EarlyNotificationSent
|
||||
&& DateTimeOffset.UtcNow
|
||||
>= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset) {
|
||||
>= scheduledEvent.ScheduledStartTime
|
||||
- GuildSettings.EventEarlyNotificationOffset.Get(data.Settings)) {
|
||||
var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct);
|
||||
if (earlyResult.IsSuccess)
|
||||
storedEvent.EarlyNotificationSent = true;
|
||||
|
@ -203,7 +205,7 @@ public class GuildUpdateService : BackgroundService {
|
|||
|
||||
var result = scheduledEvent.Status switch {
|
||||
GuildScheduledEventStatus.Scheduled =>
|
||||
await SendScheduledEventCreatedMessage(scheduledEvent, data.Configuration, ct),
|
||||
await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct),
|
||||
GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed =>
|
||||
await SendScheduledEventUpdatedMessage(scheduledEvent, data, false, ct),
|
||||
_ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)))
|
||||
|
@ -215,19 +217,17 @@ public class GuildUpdateService : BackgroundService {
|
|||
}
|
||||
|
||||
/// <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,
|
||||
/// 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>
|
||||
/// <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>
|
||||
/// <returns>A notification sending result which may or may not have succeeded.</returns>
|
||||
private async Task<Result> SendScheduledEventCreatedMessage(
|
||||
IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) {
|
||||
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
|
||||
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
|
||||
IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) {
|
||||
|
||||
if (!scheduledEvent.CreatorID.IsDefined(out var creatorId))
|
||||
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID)));
|
||||
|
@ -275,14 +275,13 @@ public class GuildUpdateService : BackgroundService {
|
|||
.WithTitle(scheduledEvent.Name)
|
||||
.WithDescription(embedDescription)
|
||||
.WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
|
||||
.WithUserFooter(currentUser)
|
||||
.WithCurrentTimestamp()
|
||||
.WithColour(ColorsList.White)
|
||||
.Build();
|
||||
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
|
||||
|
||||
var roleMention = config.EventNotificationRole is not 0
|
||||
? Mention.Role(config.EventNotificationRole.ToDiscordSnowflake())
|
||||
var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty()
|
||||
? Mention.Role(GuildSettings.EventNotificationRole.Get(settings))
|
||||
: string.Empty;
|
||||
|
||||
var button = new ButtonComponent(
|
||||
|
@ -294,14 +293,14 @@ public class GuildUpdateService : BackgroundService {
|
|||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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>
|
||||
/// <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>
|
||||
|
@ -353,7 +352,7 @@ public class GuildUpdateService : BackgroundService {
|
|||
}
|
||||
|
||||
var contentResult = await _utility.GetEventNotificationMentions(
|
||||
scheduledEvent, data.Configuration, ct);
|
||||
scheduledEvent, data.Settings, ct);
|
||||
if (!contentResult.IsDefined(out content))
|
||||
return Result.FromError(contentResult);
|
||||
|
||||
|
@ -383,7 +382,7 @@ public class GuildUpdateService : BackgroundService {
|
|||
if (!result.IsDefined(out var built)) return Result.FromError(result);
|
||||
|
||||
return (Result)await _channelApi.CreateMessageAsync(
|
||||
data.Configuration.EventNotificationChannel.ToDiscordSnowflake(),
|
||||
GuildSettings.EventNotificationChannel.Get(data.Settings),
|
||||
content ?? default(Optional<string>), embeds: new[] { built }, ct: ct);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Boyfriend.Data;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Remora.Discord.API.Abstractions.Objects;
|
||||
|
@ -103,38 +104,32 @@ public class UtilityService : IHostedService {
|
|||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If the guild configuration enables <see cref="GuildConfiguration.NotificationReceiver.Role" />, then the
|
||||
/// <see cref="GuildConfiguration.EventNotificationRole" /> will also be mentioned.
|
||||
/// </remarks>
|
||||
/// <param name="scheduledEvent">
|
||||
/// The scheduled event whose subscribers will be mentioned if the guild configuration enables
|
||||
/// <see cref="GuildConfiguration.NotificationReceiver.Interested" />.
|
||||
/// The scheduled event whose subscribers will be mentioned.
|
||||
/// </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>
|
||||
/// <returns>A result containing the string which may or may not have succeeded.</returns>
|
||||
public async Task<Result<string>> GetEventNotificationMentions(
|
||||
IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) {
|
||||
IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) {
|
||||
var builder = new StringBuilder();
|
||||
var receivers = config.EventStartedReceivers;
|
||||
var role = config.EventNotificationRole.ToDiscordSnowflake();
|
||||
var role = GuildSettings.EventNotificationRole.Get(settings);
|
||||
var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync(
|
||||
scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct);
|
||||
if (!usersResult.IsDefined(out var users)) return Result<string>.FromError(usersResult);
|
||||
|
||||
if (receivers.Contains(GuildConfiguration.NotificationReceiver.Role) && role.Value is not 0)
|
||||
if (role.Value is not 0)
|
||||
builder.Append($"{Mention.Role(role)} ");
|
||||
if (receivers.Contains(GuildConfiguration.NotificationReceiver.Interested))
|
||||
builder = users.Where(
|
||||
user => {
|
||||
if (!user.GuildMember.IsDefined(out var member)) return true;
|
||||
return !member.Roles.Contains(role);
|
||||
})
|
||||
.Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} "));
|
||||
|
||||
builder = users.Where(
|
||||
user => {
|
||||
if (!user.GuildMember.IsDefined(out var member)) return true;
|
||||
return !member.Roles.Contains(role);
|
||||
})
|
||||
.Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} "));
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
|
Reference in a new issue