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

Add a new .editorconfig and reformat code (#76)

*I'll start working on features and bugfixes after this PR, I promise*
very short summary:
- no more braceless statements
- braces are on new lines now
- `sealed` on everything that can be `sealed`
- no more awkwardly looking alignment of fields/parameters
- no more `Service` suffix on service fields. yeah.
- no more `else`s. who needs them?
- code style is now enforced by CI

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
This commit is contained in:
Octol1ttle 2023-08-03 01:51:16 +05:00 committed by GitHub
parent 4cb39a34b5
commit 84e730838b
Signed by: GitHub
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 2917 additions and 623 deletions

File diff suppressed because it is too large Load diff

View file

@ -47,46 +47,48 @@
: using a System.ComponentModel.TypeConverter : using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
--> -->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> id="root"
xmlns="">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace"/>
<xsd:element name="root" msdata:IsDataSet="true"> <xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType> <xsd:complexType>
<xsd:choice maxOccurs="unbounded"> <xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata"> <xsd:element name="metadata">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" /> <xsd:element name="value" type="xsd:string" minOccurs="0"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" /> <xsd:attribute name="name" use="required" type="xsd:string"/>
<xsd:attribute name="type" type="xsd:string" /> <xsd:attribute name="type" type="xsd:string"/>
<xsd:attribute name="mimetype" type="xsd:string" /> <xsd:attribute name="mimetype" type="xsd:string"/>
<xsd:attribute ref="xml:space" /> <xsd:attribute ref="xml:space"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="assembly"> <xsd:element name="assembly">
<xsd:complexType> <xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" /> <xsd:attribute name="alias" type="xsd:string"/>
<xsd:attribute name="name" type="xsd:string" /> <xsd:attribute name="name" type="xsd:string"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="data"> <xsd:element name="data">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1"/>
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3"/>
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4"/>
<xsd:attribute ref="xml:space" /> <xsd:attribute ref="xml:space"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="resheader"> <xsd:element name="resheader">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" /> <xsd:attribute name="name" type="xsd:string" use="required"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
</xsd:choice> </xsd:choice>
@ -100,10 +102,14 @@
<value>2.0</value> <value>2.0</value>
</resheader> </resheader>
<resheader name="reader"> <resheader name="reader">
<value> System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader> </resheader>
<resheader name="writer"> <resheader name="writer">
<value> System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader> </resheader>
<data name="Ready" xml:space="preserve"> <data name="Ready" xml:space="preserve">
<value>I'm ready!</value> <value>I'm ready!</value>

View file

@ -47,46 +47,48 @@
: using a System.ComponentModel.TypeConverter : using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
--> -->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> id="root"
xmlns="">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace"/>
<xsd:element name="root" msdata:IsDataSet="true"> <xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType> <xsd:complexType>
<xsd:choice maxOccurs="unbounded"> <xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata"> <xsd:element name="metadata">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" /> <xsd:element name="value" type="xsd:string" minOccurs="0"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" /> <xsd:attribute name="name" use="required" type="xsd:string"/>
<xsd:attribute name="type" type="xsd:string" /> <xsd:attribute name="type" type="xsd:string"/>
<xsd:attribute name="mimetype" type="xsd:string" /> <xsd:attribute name="mimetype" type="xsd:string"/>
<xsd:attribute ref="xml:space" /> <xsd:attribute ref="xml:space"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="assembly"> <xsd:element name="assembly">
<xsd:complexType> <xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" /> <xsd:attribute name="alias" type="xsd:string"/>
<xsd:attribute name="name" type="xsd:string" /> <xsd:attribute name="name" type="xsd:string"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="data"> <xsd:element name="data">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1"/>
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3"/>
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4"/>
<xsd:attribute ref="xml:space" /> <xsd:attribute ref="xml:space"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="resheader"> <xsd:element name="resheader">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" /> <xsd:attribute name="name" type="xsd:string" use="required"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
</xsd:choice> </xsd:choice>
@ -100,10 +102,14 @@
<value>2.0</value> <value>2.0</value>
</resheader> </resheader>
<resheader name="reader"> <resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader> </resheader>
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader> </resheader>
<data name="Ready" xml:space="preserve"> <data name="Ready" xml:space="preserve">
<value>Я запустился!</value> <value>Я запустился!</value>

View file

@ -47,46 +47,48 @@
: using a System.ComponentModel.TypeConverter : using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
--> -->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> id="root"
xmlns="">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace"/>
<xsd:element name="root" msdata:IsDataSet="true"> <xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType> <xsd:complexType>
<xsd:choice maxOccurs="unbounded"> <xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata"> <xsd:element name="metadata">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" /> <xsd:element name="value" type="xsd:string" minOccurs="0"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" /> <xsd:attribute name="name" use="required" type="xsd:string"/>
<xsd:attribute name="type" type="xsd:string" /> <xsd:attribute name="type" type="xsd:string"/>
<xsd:attribute name="mimetype" type="xsd:string" /> <xsd:attribute name="mimetype" type="xsd:string"/>
<xsd:attribute ref="xml:space" /> <xsd:attribute ref="xml:space"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="assembly"> <xsd:element name="assembly">
<xsd:complexType> <xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" /> <xsd:attribute name="alias" type="xsd:string"/>
<xsd:attribute name="name" type="xsd:string" /> <xsd:attribute name="name" type="xsd:string"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="data"> <xsd:element name="data">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1"/>
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3"/>
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4"/>
<xsd:attribute ref="xml:space" /> <xsd:attribute ref="xml:space"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="resheader"> <xsd:element name="resheader">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
</xsd:sequence> </xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" /> <xsd:attribute name="name" type="xsd:string" use="required"/>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
</xsd:choice> </xsd:choice>
@ -100,10 +102,14 @@
<value>2.0</value> <value>2.0</value>
</resheader> </resheader>
<resheader name="reader"> <resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader> </resheader>
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader> </resheader>
<data name="Ready" xml:space="preserve"> <data name="Ready" xml:space="preserve">
<value>я родился!</value> <value>я родился!</value>

View file

@ -22,11 +22,13 @@ using Serilog.Extensions.Logging;
namespace Boyfriend; namespace Boyfriend;
public class Boyfriend { public sealed class Boyfriend
{
public static readonly AllowedMentions NoMentions = new( public static readonly AllowedMentions NoMentions = new(
Array.Empty<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>()); Array.Empty<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>());
public static async Task Main(string[] args) { public static async Task Main(string[] args)
{
var host = CreateHostBuilder(args).UseConsoleLifetime().Build(); var host = CreateHostBuilder(args).UseConsoleLifetime().Build();
var services = host.Services; var services = host.Services;
@ -40,10 +42,12 @@ public class Boyfriend {
await host.RunAsync(); await host.RunAsync();
} }
private static IHostBuilder CreateHostBuilder(string[] args) { private static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args) return Host.CreateDefaultBuilder(args)
.AddDiscordService( .AddDiscordService(
services => { services =>
{
var configuration = services.GetRequiredService<IConfiguration>(); var configuration = services.GetRequiredService<IConfiguration>();
return configuration.GetValue<string?>("BOT_TOKEN") return configuration.GetValue<string?>("BOT_TOKEN")
@ -52,13 +56,18 @@ public class Boyfriend {
+ "BOT_TOKEN environment variable to a valid token."); + "BOT_TOKEN environment variable to a valid token.");
} }
).ConfigureServices( ).ConfigureServices(
(_, services) => { (_, services) =>
{
services.Configure<DiscordGatewayClientOptions>( services.Configure<DiscordGatewayClientOptions>(
options => options.Intents |= GatewayIntents.MessageContents options =>
{
options.Intents |= GatewayIntents.MessageContents
| GatewayIntents.GuildMembers | GatewayIntents.GuildMembers
| GatewayIntents.GuildScheduledEvents); | GatewayIntents.GuildScheduledEvents;
});
services.Configure<CacheSettings>( services.Configure<CacheSettings>(
cSettings => { cSettings =>
{
cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
cSettings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7)); cSettings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
@ -92,7 +101,10 @@ public class Boyfriend {
var responderTypes = typeof(Boyfriend).Assembly var responderTypes = typeof(Boyfriend).Assembly
.GetExportedTypes() .GetExportedTypes()
.Where(t => t.IsResponder()); .Where(t => t.IsResponder());
foreach (var responderType in responderTypes) services.AddResponder(responderType); foreach (var responderType in responderTypes)
{
services.AddResponder(responderType);
}
} }
).ConfigureLogging( ).ConfigureLogging(
c => c.AddConsole() c => c.AddConsole()

View file

@ -5,7 +5,8 @@ namespace Boyfriend;
/// <summary> /// <summary>
/// Contains all colors used in embeds. /// Contains all colors used in embeds.
/// </summary> /// </summary>
public static class ColorsList { public static class ColorsList
{
public static readonly Color Default = Color.Gray; public static readonly Color Default = Color.Gray;
public static readonly Color Red = Color.Firebrick; public static readonly Color Red = Color.Firebrick;
public static readonly Color Green = Color.PaleGreen; public static readonly Color Green = Color.PaleGreen;

View file

@ -21,19 +21,21 @@ namespace Boyfriend.Commands;
/// Handles the command to show information about this bot: /about. /// Handles the command to show information about this bot: /about.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class AboutCommandGroup : CommandGroup { public class AboutCommandGroup : CommandGroup
{
private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" }; private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" };
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly FeedbackService _feedback;
private readonly FeedbackService _feedbackService; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public AboutCommandGroup( public AboutCommandGroup(
ICommandContext context, GuildDataService dataService, ICommandContext context, GuildDataService guildData,
FeedbackService feedbackService, IDiscordRestUserAPI userApi) { FeedbackService feedback, IDiscordRestUserAPI userApi)
{
_context = context; _context = context;
_dataService = dataService; _guildData = guildData;
_feedbackService = feedbackService; _feedback = feedback;
_userApi = userApi; _userApi = userApi;
} }
@ -48,24 +50,32 @@ public class AboutCommandGroup : CommandGroup {
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[Description("Shows Boyfriend's developers")] [Description("Shows Boyfriend's developers")]
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteAboutAsync() { public async Task<Result> ExecuteAboutAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _)) if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
var cfg = await _dataService.GetSettings(guildId, CancellationToken); var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendAboutBotAsync(currentUser, CancellationToken); return await SendAboutBotAsync(currentUser, CancellationToken);
} }
private async Task<Result> SendAboutBotAsync(IUser currentUser, CancellationToken ct = default) { private async Task<Result> SendAboutBotAsync(IUser currentUser, CancellationToken ct = default)
{
var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers)); var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers));
foreach (var dev in Developers) foreach (var dev in Developers)
{
builder.AppendLine($"@{dev} — {$"AboutDeveloper@{dev}".Localized()}"); builder.AppendLine($"@{dev} — {$"AboutDeveloper@{dev}".Localized()}");
}
builder.AppendLine() builder.AppendLine()
.AppendLine(Markdown.Bold(Messages.AboutTitleWiki)) .AppendLine(Markdown.Bold(Messages.AboutTitleWiki))
@ -77,6 +87,6 @@ public class AboutCommandGroup : CommandGroup {
.WithImageUrl("https://cdn.upload.systems/uploads/JFAaX5vr.png") .WithImageUrl("https://cdn.upload.systems/uploads/JFAaX5vr.png")
.Build(); .Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
} }

View file

@ -22,23 +22,25 @@ namespace Boyfriend.Commands;
/// Handles commands related to ban management: /ban and /unban. /// Handles commands related to ban management: /ban and /unban.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class BanCommandGroup : CommandGroup { public class BanCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly FeedbackService _feedback;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility; private readonly UtilityService _utility;
public BanCommandGroup( public BanCommandGroup(
ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService, ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService guildData,
FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, FeedbackService feedback, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi,
UtilityService utility) { UtilityService utility)
{
_context = context; _context = context;
_channelApi = channelApi; _channelApi = channelApi;
_dataService = dataService; _guildData = guildData;
_feedbackService = feedbackService; _feedback = feedback;
_guildApi = guildApi; _guildApi = guildApi;
_userApi = userApi; _userApi = userApi;
_utility = utility; _utility = utility;
@ -69,21 +71,33 @@ public class BanCommandGroup : CommandGroup {
public async Task<Result> ExecuteBanAsync( public async Task<Result> ExecuteBanAsync(
[Description("User to ban")] IUser target, [Description("User to ban")] IUser target,
[Description("Ban reason")] string reason, [Description("Ban reason")] string reason,
[Description("Ban duration")] TimeSpan? duration = null) { [Description("Ban duration")] TimeSpan? duration = null)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The current user's avatar is used when sending error messages // The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
var userResult = await _userApi.GetUserAsync(userId, CancellationToken); var userResult = await _userApi.GetUserAsync(userId, CancellationToken);
if (!userResult.IsDefined(out var user)) if (!userResult.IsDefined(out var user))
{
return Result.FromError(userResult); return Result.FromError(userResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild)) if (!guildResult.IsDefined(out var guild))
{
return Result.FromError(guildResult); return Result.FromError(guildResult);
}
var data = await _dataService.GetData(guild.ID, CancellationToken); var data = await _guildData.GetData(guild.ID, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await BanUserAsync( return await BanUserAsync(
@ -92,38 +106,47 @@ public class BanCommandGroup : CommandGroup {
private async Task<Result> BanUserAsync( private async Task<Result> BanUserAsync(
IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data, Snowflake channelId, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data, Snowflake channelId,
IUser user, IUser currentUser, CancellationToken ct = default) { IUser user, IUser currentUser, CancellationToken ct = default)
{
var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct); var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct);
if (existingBanResult.IsDefined()) { if (existingBanResult.IsDefined())
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, currentUser) var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct);
} }
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync(guild.ID, user.ID, target.ID, "Ban", ct); = await _utility.CheckInteractionsAsync(guild.ID, user.ID, target.ID, "Ban", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{
return Result.FromError(interactionResult); return Result.FromError(interactionResult);
}
if (interactionResult.Entity is not null) { if (interactionResult.Entity is not null)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(errorEmbed, ct); return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct);
} }
var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason)); var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason));
if (duration is not null) if (duration is not null)
{
builder.Append( builder.Append(
string.Format( string.Format(
Messages.DescriptionActionExpiresAt, Messages.DescriptionActionExpiresAt,
Markdown.Timestamp(DateTimeOffset.UtcNow.Add(duration.Value)))); Markdown.Timestamp(DateTimeOffset.UtcNow.Add(duration.Value))));
}
var title = string.Format(Messages.UserBanned, target.GetTag()); var title = string.Format(Messages.UserBanned, target.GetTag());
var description = builder.ToString(); var description = builder.ToString();
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct); var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel)) { if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereBanned) .WithTitle(Messages.YouWereBanned)
.WithDescription(description) .WithDescription(description)
@ -133,7 +156,10 @@ public class BanCommandGroup : CommandGroup {
.Build(); .Build();
if (!dmEmbed.IsDefined(out var dmBuilt)) if (!dmEmbed.IsDefined(out var dmBuilt))
{
return Result.FromError(dmEmbed); return Result.FromError(dmEmbed);
}
await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: ct); await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: ct);
} }
@ -141,7 +167,10 @@ public class BanCommandGroup : CommandGroup {
guild.ID, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(), guild.ID, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(),
ct: ct); ct: ct);
if (!banResult.IsSuccess) if (!banResult.IsSuccess)
{
return Result.FromError(banResult.Error); return Result.FromError(banResult.Error);
}
var memberData = data.GetMemberData(target.ID); var memberData = data.GetMemberData(target.ID);
memberData.BannedUntil memberData.BannedUntil
= duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue; = duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue;
@ -154,9 +183,11 @@ public class BanCommandGroup : CommandGroup {
var logResult = _utility.LogActionAsync( var logResult = _utility.LogActionAsync(
data.Settings, channelId, user, title, description, target, ColorsList.Red, ct: ct); data.Settings, channelId, user, title, description, target, ColorsList.Red, ct: ct);
if (!logResult.IsSuccess) if (!logResult.IsSuccess)
{
return Result.FromError(logResult.Error); return Result.FromError(logResult.Error);
}
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
/// <summary> /// <summary>
@ -172,7 +203,7 @@ public class BanCommandGroup : CommandGroup {
/// was unbanned and vice-versa. /// was unbanned and vice-versa.
/// </returns> /// </returns>
/// <seealso cref="ExecuteBanAsync" /> /// <seealso cref="ExecuteBanAsync" />
/// <seealso cref="GuildUpdateService.TickGuildAsync"/> /// <seealso cref="GuildUpdateService.TickGuildAsync" />
[Command("unban")] [Command("unban")]
[DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] [DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)]
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
@ -183,19 +214,28 @@ public class BanCommandGroup : CommandGroup {
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteUnban( public async Task<Result> ExecuteUnban(
[Description("User to unban")] IUser target, [Description("User to unban")] IUser target,
[Description("Unban reason")] string reason) { [Description("Unban reason")] string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The current user's avatar is used when sending error messages // The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
// Needed to get the tag and avatar // Needed to get the tag and avatar
var userResult = await _userApi.GetUserAsync(userId, CancellationToken); var userResult = await _userApi.GetUserAsync(userId, CancellationToken);
if (!userResult.IsDefined(out var user)) if (!userResult.IsDefined(out var user))
{
return Result.FromError(userResult); return Result.FromError(userResult);
}
var data = await _dataService.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await UnbanUserAsync( return await UnbanUserAsync(
@ -204,20 +244,24 @@ public class BanCommandGroup : CommandGroup {
private async Task<Result> UnbanUserAsync( private async Task<Result> UnbanUserAsync(
IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId, IUser user, IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId, IUser user,
IUser currentUser, CancellationToken ct = default) { IUser currentUser, CancellationToken ct = default)
{
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, target.ID, ct); var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, target.ID, ct);
if (!existingBanResult.IsDefined()) { if (!existingBanResult.IsDefined())
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotBanned, currentUser) var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotBanned, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(errorEmbed, ct); return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct);
} }
var unbanResult = await _guildApi.RemoveGuildBanAsync( var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), guildId, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(),
ct); ct);
if (!unbanResult.IsSuccess) if (!unbanResult.IsSuccess)
{
return Result.FromError(unbanResult.Error); return Result.FromError(unbanResult.Error);
}
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnbanned, target.GetTag()), target) string.Format(Messages.UserUnbanned, target.GetTag()), target)
@ -228,8 +272,10 @@ public class BanCommandGroup : CommandGroup {
var logResult = _utility.LogActionAsync( var logResult = _utility.LogActionAsync(
data.Settings, channelId, user, title, description, target, ColorsList.Green, ct: ct); data.Settings, channelId, user, title, description, target, ColorsList.Green, ct: ct);
if (!logResult.IsSuccess) if (!logResult.IsSuccess)
{
return Result.FromError(logResult.Error); return Result.FromError(logResult.Error);
}
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
} }

View file

@ -22,21 +22,23 @@ namespace Boyfriend.Commands;
/// Handles the command to clear messages in a channel: /clear. /// Handles the command to clear messages in a channel: /clear.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class ClearCommandGroup : CommandGroup { public class ClearCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly FeedbackService _feedback;
private readonly FeedbackService _feedbackService; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility; private readonly UtilityService _utility;
public ClearCommandGroup( public ClearCommandGroup(
IDiscordRestChannelAPI channelApi, ICommandContext context, GuildDataService dataService, IDiscordRestChannelAPI channelApi, ICommandContext context, GuildDataService guildData,
FeedbackService feedbackService, IDiscordRestUserAPI userApi, UtilityService utility) { FeedbackService feedback, IDiscordRestUserAPI userApi, UtilityService utility)
{
_channelApi = channelApi; _channelApi = channelApi;
_context = context; _context = context;
_dataService = dataService; _guildData = guildData;
_feedbackService = feedbackService; _feedback = feedback;
_userApi = userApi; _userApi = userApi;
_utility = utility; _utility = utility;
} }
@ -59,23 +61,34 @@ public class ClearCommandGroup : CommandGroup {
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteClear( public async Task<Result> ExecuteClear(
[Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)] [Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)]
int amount) { int amount)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var messagesResult = await _channelApi.GetChannelMessagesAsync( var messagesResult = await _channelApi.GetChannelMessagesAsync(
channelId, limit: amount + 1, ct: CancellationToken); channelId, limit: amount + 1, ct: CancellationToken);
if (!messagesResult.IsDefined(out var messages)) if (!messagesResult.IsDefined(out var messages))
{
return Result.FromError(messagesResult); return Result.FromError(messagesResult);
}
var userResult = await _userApi.GetUserAsync(userId, CancellationToken); var userResult = await _userApi.GetUserAsync(userId, CancellationToken);
if (!userResult.IsDefined(out var user)) if (!userResult.IsDefined(out var user))
{
return Result.FromError(userResult); return Result.FromError(userResult);
}
// The current user's avatar is used when sending messages // The current user's avatar is used when sending messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
var data = await _dataService.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ClearMessagesAsync(amount, data, channelId, messages, user, currentUser, CancellationToken); return await ClearMessagesAsync(amount, data, channelId, messages, user, currentUser, CancellationToken);
@ -83,10 +96,12 @@ public class ClearCommandGroup : CommandGroup {
private async Task<Result> ClearMessagesAsync( private async Task<Result> ClearMessagesAsync(
int amount, GuildData data, Snowflake channelId, IReadOnlyList<IMessage> messages, int amount, GuildData data, Snowflake channelId, IReadOnlyList<IMessage> messages,
IUser user, IUser currentUser, CancellationToken ct = default) { IUser user, IUser currentUser, CancellationToken ct = default)
{
var idList = new List<Snowflake>(messages.Count); var idList = new List<Snowflake>(messages.Count);
var builder = new StringBuilder().AppendLine(Mention.Channel(channelId)).AppendLine(); var builder = new StringBuilder().AppendLine(Mention.Channel(channelId)).AppendLine();
for (var i = messages.Count - 1; i >= 1; i--) { // '>= 1' to skip last message ('Boyfriend is thinking...') for (var i = messages.Count - 1; i >= 1; i--) // '>= 1' to skip last message ('Boyfriend is thinking...')
{
var message = messages[i]; var message = messages[i];
idList.Add(message.ID); idList.Add(message.ID);
builder.AppendLine(string.Format(Messages.MessageFrom, Mention.User(message.Author))); builder.AppendLine(string.Format(Messages.MessageFrom, Mention.User(message.Author)));
@ -99,16 +114,20 @@ public class ClearCommandGroup : CommandGroup {
var deleteResult = await _channelApi.BulkDeleteMessagesAsync( var deleteResult = await _channelApi.BulkDeleteMessagesAsync(
channelId, idList, user.GetTag().EncodeHeader(), ct); channelId, idList, user.GetTag().EncodeHeader(), ct);
if (!deleteResult.IsSuccess) if (!deleteResult.IsSuccess)
{
return Result.FromError(deleteResult.Error); return Result.FromError(deleteResult.Error);
}
var logResult = _utility.LogActionAsync( var logResult = _utility.LogActionAsync(
data.Settings, channelId, user, title, description, currentUser, ColorsList.Red, false, ct); data.Settings, channelId, user, title, description, currentUser, ColorsList.Red, false, ct);
if (!logResult.IsSuccess) if (!logResult.IsSuccess)
{
return Result.FromError(logResult.Error); return Result.FromError(logResult.Error);
}
var embed = new EmbedBuilder().WithSmallTitle(title, currentUser) var embed = new EmbedBuilder().WithSmallTitle(title, currentUser)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
} }

View file

@ -11,10 +11,12 @@ namespace Boyfriend.Commands.Events;
/// Handles error logging for slash command groups. /// Handles error logging for slash command groups.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent { public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
{
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger; private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
public ErrorLoggingPostExecutionEvent(ILogger<ErrorLoggingPostExecutionEvent> logger) { public ErrorLoggingPostExecutionEvent(ILogger<ErrorLoggingPostExecutionEvent> logger)
{
_logger = logger; _logger = logger;
} }
@ -27,12 +29,16 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent {
/// <param name="ct">The cancellation token for this operation. Unused.</param> /// <param name="ct">The cancellation token for this operation. Unused.</param>
/// <returns>A result which has succeeded.</returns> /// <returns>A result which has succeeded.</returns>
public Task<Result> AfterExecutionAsync( public Task<Result> AfterExecutionAsync(
ICommandContext context, IResult commandResult, CancellationToken ct = default) { ICommandContext context, IResult commandResult, CancellationToken ct = default)
if (!commandResult.IsSuccess && !commandResult.Error.IsUserOrEnvironmentError()) { {
if (!commandResult.IsSuccess && !commandResult.Error.IsUserOrEnvironmentError())
{
_logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message); _logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message);
if (commandResult.Error is ExceptionError exerr) if (commandResult.Error is ExceptionError exerr)
{
_logger.LogError(exerr.Exception, "An exception has been thrown"); _logger.LogError(exerr.Exception, "An exception has been thrown");
} }
}
return Task.FromResult(Result.FromSuccess()); return Task.FromResult(Result.FromSuccess());
} }

View file

@ -11,10 +11,12 @@ namespace Boyfriend.Commands.Events;
/// Handles error logging for slash commands that couldn't be successfully prepared. /// Handles error logging for slash commands that couldn't be successfully prepared.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class LoggingPreparationErrorEvent : IPreparationErrorEvent { public class LoggingPreparationErrorEvent : IPreparationErrorEvent
{
private readonly ILogger<LoggingPreparationErrorEvent> _logger; private readonly ILogger<LoggingPreparationErrorEvent> _logger;
public LoggingPreparationErrorEvent(ILogger<LoggingPreparationErrorEvent> logger) { public LoggingPreparationErrorEvent(ILogger<LoggingPreparationErrorEvent> logger)
{
_logger = logger; _logger = logger;
} }
@ -27,12 +29,16 @@ public class LoggingPreparationErrorEvent : IPreparationErrorEvent {
/// <param name="ct">The cancellation token for this operation. Unused.</param> /// <param name="ct">The cancellation token for this operation. Unused.</param>
/// <returns>A result which has succeeded.</returns> /// <returns>A result which has succeeded.</returns>
public Task<Result> PreparationFailed( public Task<Result> PreparationFailed(
IOperationContext context, IResult preparationResult, CancellationToken ct = default) { IOperationContext context, IResult preparationResult, CancellationToken ct = default)
if (!preparationResult.IsSuccess && !preparationResult.Error.IsUserOrEnvironmentError()) { {
if (!preparationResult.IsSuccess && !preparationResult.Error.IsUserOrEnvironmentError())
{
_logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message); _logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message);
if (preparationResult.Error is ExceptionError exerr) if (preparationResult.Error is ExceptionError exerr)
{
_logger.LogError(exerr.Exception, "An exception has been thrown"); _logger.LogError(exerr.Exception, "An exception has been thrown");
} }
}
return Task.FromResult(Result.FromSuccess()); return Task.FromResult(Result.FromSuccess());
} }

View file

@ -20,23 +20,25 @@ namespace Boyfriend.Commands;
/// Handles the command to kick members of a guild: /kick. /// Handles the command to kick members of a guild: /kick.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class KickCommandGroup : CommandGroup { public class KickCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly FeedbackService _feedback;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility; private readonly UtilityService _utility;
public KickCommandGroup( public KickCommandGroup(
ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService, ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService guildData,
FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, FeedbackService feedback, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi,
UtilityService utility) { UtilityService utility)
{
_context = context; _context = context;
_channelApi = channelApi; _channelApi = channelApi;
_dataService = dataService; _guildData = guildData;
_feedbackService = feedbackService; _feedback = feedback;
_guildApi = guildApi; _guildApi = guildApi;
_userApi = userApi; _userApi = userApi;
_utility = utility; _utility = utility;
@ -64,29 +66,42 @@ public class KickCommandGroup : CommandGroup {
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteKick( public async Task<Result> ExecuteKick(
[Description("Member to kick")] IUser target, [Description("Member to kick")] IUser target,
[Description("Kick reason")] string reason) { [Description("Kick reason")] string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The current user's avatar is used when sending error messages // The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
var userResult = await _userApi.GetUserAsync(userId, CancellationToken); var userResult = await _userApi.GetUserAsync(userId, CancellationToken);
if (!userResult.IsDefined(out var user)) if (!userResult.IsDefined(out var user))
{
return Result.FromError(userResult); return Result.FromError(userResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild)) if (!guildResult.IsDefined(out var guild))
{
return Result.FromError(guildResult); return Result.FromError(guildResult);
}
var data = await _dataService.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken); var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess) { if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser) var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); return await _feedback.SendContextualEmbedResultAsync(embed, CancellationToken);
} }
return await KickUserAsync(target, reason, guild, channelId, data, user, currentUser, CancellationToken); return await KickUserAsync(target, reason, guild, channelId, data, user, currentUser, CancellationToken);
@ -94,21 +109,26 @@ public class KickCommandGroup : CommandGroup {
private async Task<Result> KickUserAsync( private async Task<Result> KickUserAsync(
IUser target, string reason, IGuild guild, Snowflake channelId, GuildData data, IUser user, IUser currentUser, IUser target, string reason, IGuild guild, Snowflake channelId, GuildData data, IUser user, IUser currentUser,
CancellationToken ct = default) { CancellationToken ct = default)
{
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync(guild.ID, user.ID, target.ID, "Kick", ct); = await _utility.CheckInteractionsAsync(guild.ID, user.ID, target.ID, "Kick", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{
return Result.FromError(interactionResult); return Result.FromError(interactionResult);
}
if (interactionResult.Entity is not null) { if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct);
} }
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct); var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel)) { if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereKicked) .WithTitle(Messages.YouWereKicked)
.WithDescription(string.Format(Messages.DescriptionActionReason, reason)) .WithDescription(string.Format(Messages.DescriptionActionReason, reason))
@ -118,7 +138,10 @@ public class KickCommandGroup : CommandGroup {
.Build(); .Build();
if (!dmEmbed.IsDefined(out var dmBuilt)) if (!dmEmbed.IsDefined(out var dmBuilt))
{
return Result.FromError(dmEmbed); return Result.FromError(dmEmbed);
}
await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: ct); await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: ct);
} }
@ -126,7 +149,10 @@ public class KickCommandGroup : CommandGroup {
guild.ID, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), guild.ID, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(),
ct); ct);
if (!kickResult.IsSuccess) if (!kickResult.IsSuccess)
{
return Result.FromError(kickResult.Error); return Result.FromError(kickResult.Error);
}
data.GetMemberData(target.ID).Roles.Clear(); data.GetMemberData(target.ID).Roles.Clear();
var title = string.Format(Messages.UserKicked, target.GetTag()); var title = string.Format(Messages.UserKicked, target.GetTag());
@ -134,12 +160,14 @@ public class KickCommandGroup : CommandGroup {
var logResult = _utility.LogActionAsync( var logResult = _utility.LogActionAsync(
data.Settings, channelId, user, title, description, target, ColorsList.Red, ct: ct); data.Settings, channelId, user, title, description, target, ColorsList.Red, ct: ct);
if (!logResult.IsSuccess) if (!logResult.IsSuccess)
{
return Result.FromError(logResult.Error); return Result.FromError(logResult.Error);
}
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserKicked, target.GetTag()), target) string.Format(Messages.UserKicked, target.GetTag()), target)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
} }

View file

@ -22,20 +22,22 @@ namespace Boyfriend.Commands;
/// Handles commands related to mute management: /mute and /unmute. /// Handles commands related to mute management: /mute and /unmute.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class MuteCommandGroup : CommandGroup { public class MuteCommandGroup : CommandGroup
{
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly FeedbackService _feedback;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility; private readonly UtilityService _utility;
public MuteCommandGroup( public MuteCommandGroup(
ICommandContext context, GuildDataService dataService, FeedbackService feedbackService, ICommandContext context, GuildDataService guildData, FeedbackService feedback,
IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, UtilityService utility) { IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, UtilityService utility)
{
_context = context; _context = context;
_dataService = dataService; _guildData = guildData;
_feedbackService = feedbackService; _feedback = feedback;
_guildApi = guildApi; _guildApi = guildApi;
_userApi = userApi; _userApi = userApi;
_utility = utility; _utility = utility;
@ -66,28 +68,36 @@ public class MuteCommandGroup : CommandGroup {
public async Task<Result> ExecuteMute( public async Task<Result> ExecuteMute(
[Description("Member to mute")] IUser target, [Description("Member to mute")] IUser target,
[Description("Mute reason")] string reason, [Description("Mute reason")] string reason,
[Description("Mute duration")] TimeSpan duration) { [Description("Mute duration")] TimeSpan duration)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The current user's avatar is used when sending error messages // The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
var userResult = await _userApi.GetUserAsync(userId, CancellationToken); var userResult = await _userApi.GetUserAsync(userId, CancellationToken);
if (!userResult.IsDefined(out var user)) if (!userResult.IsDefined(out var user))
{
return Result.FromError(userResult); return Result.FromError(userResult);
}
var data = await _dataService.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken); var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess) { if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser) var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); return await _feedback.SendContextualEmbedResultAsync(embed, CancellationToken);
} }
return await MuteUserAsync( return await MuteUserAsync(
@ -96,18 +106,22 @@ public class MuteCommandGroup : CommandGroup {
private async Task<Result> MuteUserAsync( private async Task<Result> MuteUserAsync(
IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data, Snowflake channelId, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data, Snowflake channelId,
IUser user, IUser currentUser, CancellationToken ct = default) { IUser user, IUser currentUser, CancellationToken ct = default)
{
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync( = await _utility.CheckInteractionsAsync(
guildId, user.ID, target.ID, "Mute", ct); guildId, user.ID, target.ID, "Mute", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{
return Result.FromError(interactionResult); return Result.FromError(interactionResult);
}
if (interactionResult.Entity is not null) { if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct);
} }
var until = DateTimeOffset.UtcNow.Add(duration); // >:) var until = DateTimeOffset.UtcNow.Add(duration); // >:)
@ -115,7 +129,9 @@ public class MuteCommandGroup : CommandGroup {
guildId, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(), guildId, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(),
communicationDisabledUntil: until, ct: ct); communicationDisabledUntil: until, ct: ct);
if (!muteResult.IsSuccess) if (!muteResult.IsSuccess)
{
return Result.FromError(muteResult.Error); return Result.FromError(muteResult.Error);
}
var title = string.Format(Messages.UserMuted, target.GetTag()); var title = string.Format(Messages.UserMuted, target.GetTag());
var description = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason)) var description = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason))
@ -126,13 +142,15 @@ public class MuteCommandGroup : CommandGroup {
var logResult = _utility.LogActionAsync( var logResult = _utility.LogActionAsync(
data.Settings, channelId, user, title, description, target, ColorsList.Red, ct: ct); data.Settings, channelId, user, title, description, target, ColorsList.Red, ct: ct);
if (!logResult.IsSuccess) if (!logResult.IsSuccess)
{
return Result.FromError(logResult.Error); return Result.FromError(logResult.Error);
}
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserMuted, target.GetTag()), target) string.Format(Messages.UserMuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
/// <summary> /// <summary>
@ -148,7 +166,7 @@ public class MuteCommandGroup : CommandGroup {
/// was unmuted and vice-versa. /// was unmuted and vice-versa.
/// </returns> /// </returns>
/// <seealso cref="ExecuteMute" /> /// <seealso cref="ExecuteMute" />
/// <seealso cref="GuildUpdateService.TickGuildAsync"/> /// <seealso cref="GuildUpdateService.TickGuildAsync" />
[Command("unmute", "размут")] [Command("unmute", "размут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] [DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)]
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
@ -159,29 +177,37 @@ public class MuteCommandGroup : CommandGroup {
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteUnmute( public async Task<Result> ExecuteUnmute(
[Description("Member to unmute")] IUser target, [Description("Member to unmute")] IUser target,
[Description("Unmute reason")] string reason) { [Description("Unmute reason")] string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The current user's avatar is used when sending error messages // The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
// Needed to get the tag and avatar // Needed to get the tag and avatar
var userResult = await _userApi.GetUserAsync(userId, CancellationToken); var userResult = await _userApi.GetUserAsync(userId, CancellationToken);
if (!userResult.IsDefined(out var user)) if (!userResult.IsDefined(out var user))
{
return Result.FromError(userResult); return Result.FromError(userResult);
}
var data = await _dataService.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken); var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess) { if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser) var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); return await _feedback.SendContextualEmbedResultAsync(embed, CancellationToken);
} }
return await UnmuteUserAsync( return await UnmuteUserAsync(
@ -190,37 +216,45 @@ public class MuteCommandGroup : CommandGroup {
private async Task<Result> UnmuteUserAsync( private async Task<Result> UnmuteUserAsync(
IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId, IUser user, IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId, IUser user,
IUser currentUser, CancellationToken ct = default) { IUser currentUser, CancellationToken ct = default)
{
var interactionResult var interactionResult
= await _utility.CheckInteractionsAsync( = await _utility.CheckInteractionsAsync(
guildId, user.ID, target.ID, "Unmute", ct); guildId, user.ID, target.ID, "Unmute", ct);
if (!interactionResult.IsSuccess) if (!interactionResult.IsSuccess)
{
return Result.FromError(interactionResult); return Result.FromError(interactionResult);
}
if (interactionResult.Entity is not null) { if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser)
.WithColour(ColorsList.Red).Build(); .WithColour(ColorsList.Red).Build();
return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct);
} }
var unmuteResult = await _guildApi.ModifyGuildMemberAsync( var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), guildId, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(),
communicationDisabledUntil: null, ct: ct); communicationDisabledUntil: null, ct: ct);
if (!unmuteResult.IsSuccess) if (!unmuteResult.IsSuccess)
{
return Result.FromError(unmuteResult.Error); return Result.FromError(unmuteResult.Error);
}
var title = string.Format(Messages.UserUnmuted, target.GetTag()); var title = string.Format(Messages.UserUnmuted, target.GetTag());
var description = string.Format(Messages.DescriptionActionReason, reason); var description = string.Format(Messages.DescriptionActionReason, reason);
var logResult = _utility.LogActionAsync( var logResult = _utility.LogActionAsync(
data.Settings, channelId, user, title, description, target, ColorsList.Green, ct: ct); data.Settings, channelId, user, title, description, target, ColorsList.Green, ct: ct);
if (!logResult.IsSuccess) if (!logResult.IsSuccess)
{
return Result.FromError(logResult.Error); return Result.FromError(logResult.Error);
}
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnmuted, target.GetTag()), target) string.Format(Messages.UserUnmuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build(); .WithColour(ColorsList.Green).Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
} }

View file

@ -21,22 +21,24 @@ namespace Boyfriend.Commands;
/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping /// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class PingCommandGroup : CommandGroup { public class PingCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client; private readonly DiscordGatewayClient _client;
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly FeedbackService _feedback;
private readonly FeedbackService _feedbackService; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public PingCommandGroup( public PingCommandGroup(
IDiscordRestChannelAPI channelApi, ICommandContext context, DiscordGatewayClient client, IDiscordRestChannelAPI channelApi, ICommandContext context, DiscordGatewayClient client,
GuildDataService dataService, FeedbackService feedbackService, IDiscordRestUserAPI userApi) { GuildDataService guildData, FeedbackService feedback, IDiscordRestUserAPI userApi)
{
_channelApi = channelApi; _channelApi = channelApi;
_context = context; _context = context;
_client = client; _client = client;
_dataService = dataService; _guildData = guildData;
_feedbackService = feedbackService; _feedback = feedback;
_userApi = userApi; _userApi = userApi;
} }
@ -51,29 +53,39 @@ public class PingCommandGroup : CommandGroup {
[DiscordDefaultDMPermission(false)] [DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)] [RequireContext(ChannelContext.Guild)]
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecutePingAsync() { public async Task<Result> ExecutePingAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
var cfg = await _dataService.GetSettings(guildId, CancellationToken); var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendLatencyAsync(channelId, currentUser, CancellationToken); return await SendLatencyAsync(channelId, currentUser, CancellationToken);
} }
private async Task<Result> SendLatencyAsync( private async Task<Result> SendLatencyAsync(
Snowflake channelId, IUser currentUser, CancellationToken ct = default) { Snowflake channelId, IUser currentUser, CancellationToken ct = default)
{
var latency = _client.Latency.TotalMilliseconds; var latency = _client.Latency.TotalMilliseconds;
if (latency is 0) { if (latency is 0)
{
// No heartbeat has occurred, estimate latency from local time and "Boyfriend is thinking..." message // No heartbeat has occurred, estimate latency from local time and "Boyfriend is thinking..." message
var lastMessageResult = await _channelApi.GetChannelMessagesAsync( var lastMessageResult = await _channelApi.GetChannelMessagesAsync(
channelId, limit: 1, ct: ct); channelId, limit: 1, ct: ct);
if (!lastMessageResult.IsDefined(out var lastMessage)) if (!lastMessageResult.IsDefined(out var lastMessage))
{
return Result.FromError(lastMessageResult); return Result.FromError(lastMessageResult);
}
latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds; latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds;
} }
@ -84,6 +96,6 @@ public class PingCommandGroup : CommandGroup {
.WithCurrentTimestamp() .WithCurrentTimestamp()
.Build(); .Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
} }

View file

@ -21,18 +21,20 @@ namespace Boyfriend.Commands;
/// Handles the command to manage reminders: /remind /// Handles the command to manage reminders: /remind
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class RemindCommandGroup : CommandGroup { public class RemindCommandGroup : CommandGroup
{
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly FeedbackService _feedback;
private readonly FeedbackService _feedbackService; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public RemindCommandGroup( public RemindCommandGroup(
ICommandContext context, GuildDataService dataService, FeedbackService feedbackService, ICommandContext context, GuildDataService guildData, FeedbackService feedback,
IDiscordRestUserAPI userApi) { IDiscordRestUserAPI userApi)
{
_context = context; _context = context;
_dataService = dataService; _guildData = guildData;
_feedbackService = feedbackService; _feedback = feedback;
_userApi = userApi; _userApi = userApi;
} }
@ -50,15 +52,20 @@ public class RemindCommandGroup : CommandGroup {
public async Task<Result> ExecuteReminderAsync( public async Task<Result> ExecuteReminderAsync(
[Description("After what period of time mention the reminder")] [Description("After what period of time mention the reminder")]
TimeSpan @in, TimeSpan @in,
[Description("Reminder message")] string message) { [Description("Reminder message")] string message)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var userResult = await _userApi.GetUserAsync(userId, CancellationToken); var userResult = await _userApi.GetUserAsync(userId, CancellationToken);
if (!userResult.IsDefined(out var user)) if (!userResult.IsDefined(out var user))
{
return Result.FromError(userResult); return Result.FromError(userResult);
}
var data = await _dataService.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await AddReminderAsync(@in, message, data, channelId, user, CancellationToken); return await AddReminderAsync(@in, message, data, channelId, user, CancellationToken);
@ -66,11 +73,13 @@ public class RemindCommandGroup : CommandGroup {
private async Task<Result> AddReminderAsync( private async Task<Result> AddReminderAsync(
TimeSpan @in, string message, GuildData data, TimeSpan @in, string message, GuildData data,
Snowflake channelId, IUser user, CancellationToken ct = default) { Snowflake channelId, IUser user, CancellationToken ct = default)
{
var remindAt = DateTimeOffset.UtcNow.Add(@in); var remindAt = DateTimeOffset.UtcNow.Add(@in);
data.GetMemberData(user.ID).Reminders.Add( data.GetMemberData(user.ID).Reminders.Add(
new Reminder { new Reminder
{
At = remindAt, At = remindAt,
Channel = channelId.Value, Channel = channelId.Value,
Text = message Text = message
@ -81,6 +90,6 @@ public class RemindCommandGroup : CommandGroup {
.WithColour(ColorsList.Green) .WithColour(ColorsList.Green)
.Build(); .Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
} }

View file

@ -23,8 +23,10 @@ namespace Boyfriend.Commands;
/// Handles the commands to list and modify per-guild settings: /settings and /settings list. /// Handles the commands to list and modify per-guild settings: /settings and /settings list.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class SettingsCommandGroup : CommandGroup { public class SettingsCommandGroup : CommandGroup
private static readonly IOption[] AllOptions = { {
private static readonly IOption[] AllOptions =
{
GuildSettings.Language, GuildSettings.Language,
GuildSettings.WelcomeMessage, GuildSettings.WelcomeMessage,
GuildSettings.ReceiveStartupMessages, GuildSettings.ReceiveStartupMessages,
@ -42,16 +44,17 @@ public class SettingsCommandGroup : CommandGroup {
}; };
private readonly ICommandContext _context; private readonly ICommandContext _context;
private readonly GuildDataService _dataService; private readonly FeedbackService _feedback;
private readonly FeedbackService _feedbackService; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public SettingsCommandGroup( public SettingsCommandGroup(
ICommandContext context, GuildDataService dataService, ICommandContext context, GuildDataService guildData,
FeedbackService feedbackService, IDiscordRestUserAPI userApi) { FeedbackService feedback, IDiscordRestUserAPI userApi)
{
_context = context; _context = context;
_dataService = dataService; _guildData = guildData;
_feedbackService = feedbackService; _feedback = feedback;
_userApi = userApi; _userApi = userApi;
} }
@ -69,21 +72,28 @@ public class SettingsCommandGroup : CommandGroup {
[Description("Shows settings list for this server")] [Description("Shows settings list for this server")]
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> ExecuteSettingsListAsync( public async Task<Result> ExecuteSettingsListAsync(
[Description("Settings list page")] int page) { [Description("Settings list page")] int page)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _)) if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
var cfg = await _dataService.GetSettings(guildId, CancellationToken); var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendSettingsListAsync(cfg, currentUser, page, CancellationToken); return await SendSettingsListAsync(cfg, currentUser, page, CancellationToken);
} }
private async Task<Result> SendSettingsListAsync(JsonNode cfg, IUser currentUser, int page, CancellationToken ct = default) { private async Task<Result> SendSettingsListAsync(JsonNode cfg, IUser currentUser, int page,
CancellationToken ct = default)
{
var description = new StringBuilder(); var description = new StringBuilder();
var footer = new StringBuilder(); var footer = new StringBuilder();
@ -93,18 +103,24 @@ public class SettingsCommandGroup : CommandGroup {
var lastOptionOnPage = Math.Min(optionsPerPage * page, AllOptions.Length); var lastOptionOnPage = Math.Min(optionsPerPage * page, AllOptions.Length);
var firstOptionOnPage = optionsPerPage * page - optionsPerPage; var firstOptionOnPage = optionsPerPage * page - optionsPerPage;
if (firstOptionOnPage >= AllOptions.Length) { if (firstOptionOnPage >= AllOptions.Length)
var embed = new EmbedBuilder().WithSmallTitle(Messages.PageNotFound, currentUser) {
var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.PageNotFound, currentUser)
.WithDescription(string.Format(Messages.PagesAllowed, Markdown.Bold(totalPages.ToString()))) .WithDescription(string.Format(Messages.PagesAllowed, Markdown.Bold(totalPages.ToString())))
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
.Build(); .Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct);
} else { }
footer.Append($"{Messages.Page} {page}/{totalPages} ");
for (var i = 0; i < totalPages; i++) footer.Append(i + 1 == page ? "●" : "○");
for (var i = firstOptionOnPage; i < lastOptionOnPage; i++) { footer.Append($"{Messages.Page} {page}/{totalPages} ");
for (var i = 0; i < totalPages; i++)
{
footer.Append(i + 1 == page ? "●" : "○");
}
for (var i = firstOptionOnPage; i < lastOptionOnPage; i++)
{
var optionName = AllOptions[i].Name; var optionName = AllOptions[i].Name;
var optionValue = AllOptions[i].Display(cfg); var optionValue = AllOptions[i].Display(cfg);
@ -119,8 +135,7 @@ public class SettingsCommandGroup : CommandGroup {
.WithFooter(footer.ToString()) .WithFooter(footer.ToString())
.Build(); .Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
}
} }
/// <summary> /// <summary>
@ -139,33 +154,40 @@ public class SettingsCommandGroup : CommandGroup {
public async Task<Result> ExecuteSettingsAsync( public async Task<Result> ExecuteSettingsAsync(
[Description("The setting whose value you want to change")] [Description("The setting whose value you want to change")]
string setting, string setting,
[Description("Setting value")] string value) { [Description("Setting value")] string value)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _)) if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult); return Result.FromError(currentUserResult);
}
var data = await _dataService.GetData(guildId, CancellationToken); var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await EditSettingAsync(setting, value, data, currentUser, CancellationToken); return await EditSettingAsync(setting, value, data, currentUser, CancellationToken);
} }
private async Task<Result> EditSettingAsync( private async Task<Result> EditSettingAsync(
string setting, string value, GuildData data, IUser currentUser, CancellationToken ct = default) { string setting, string value, GuildData data, IUser currentUser, CancellationToken ct = default)
{
var option = AllOptions.Single( var option = AllOptions.Single(
o => string.Equals(setting, o.Name, StringComparison.InvariantCultureIgnoreCase)); o => string.Equals(setting, o.Name, StringComparison.InvariantCultureIgnoreCase));
var setResult = option.Set(data.Settings, value); var setResult = option.Set(data.Settings, value);
if (!setResult.IsSuccess) { if (!setResult.IsSuccess)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, currentUser) var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, currentUser)
.WithDescription(setResult.Error.Message) .WithDescription(setResult.Error.Message)
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
.Build(); .Build();
return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct);
} }
var builder = new StringBuilder(); var builder = new StringBuilder();
@ -179,6 +201,6 @@ public class SettingsCommandGroup : CommandGroup {
.WithColour(ColorsList.Green) .WithColour(ColorsList.Green)
.Build(); .Build();
return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); return await _feedback.SendContextualEmbedResultAsync(embed, ct);
} }
} }

View file

@ -7,7 +7,8 @@ namespace Boyfriend.Data;
/// Stores information about a guild. This information is not accessible via the Discord API. /// Stores information about a guild. This information is not accessible via the Discord API.
/// </summary> /// </summary>
/// <remarks>This information is stored on disk as a JSON file.</remarks> /// <remarks>This information is stored on disk as a JSON file.</remarks>
public class GuildData { public sealed class GuildData
{
public readonly Dictionary<ulong, MemberData> MemberData; public readonly Dictionary<ulong, MemberData> MemberData;
public readonly string MemberDataPath; public readonly string MemberDataPath;
@ -19,7 +20,8 @@ public class GuildData {
public GuildData( public GuildData(
JsonNode settings, string settingsPath, JsonNode settings, string settingsPath,
Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath, Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
Dictionary<ulong, MemberData> memberData, string memberDataPath) { Dictionary<ulong, MemberData> memberData, string memberDataPath)
{
Settings = settings; Settings = settings;
SettingsPath = settingsPath; SettingsPath = settingsPath;
ScheduledEvents = scheduledEvents; ScheduledEvents = scheduledEvents;
@ -28,8 +30,12 @@ public class GuildData {
MemberDataPath = memberDataPath; MemberDataPath = memberDataPath;
} }
public MemberData GetMemberData(Snowflake userId) { public MemberData GetMemberData(Snowflake userId)
if (MemberData.TryGetValue(userId.Value, out var existing)) return existing; {
if (MemberData.TryGetValue(userId.Value, out var existing))
{
return existing;
}
var newData = new MemberData(userId.Value, null); var newData = new MemberData(userId.Value, null);
MemberData.Add(userId.Value, newData); MemberData.Add(userId.Value, newData);

View file

@ -8,7 +8,8 @@ namespace Boyfriend.Data;
/// Contains all per-guild settings that can be set by a member /// Contains all per-guild settings that can be set by a member
/// with <see cref="DiscordPermission.ManageGuild" /> using the /settings command /// with <see cref="DiscordPermission.ManageGuild" /> using the /settings command
/// </summary> /// </summary>
public static class GuildSettings { public static class GuildSettings
{
public static readonly LanguageOption Language = new("Language", "en"); public static readonly LanguageOption Language = new("Language", "en");
/// <summary> /// <summary>

View file

@ -3,8 +3,10 @@ namespace Boyfriend.Data;
/// <summary> /// <summary>
/// Stores information about a member /// Stores information about a member
/// </summary> /// </summary>
public class MemberData { public sealed class MemberData
public MemberData(ulong id, DateTimeOffset? bannedUntil) { {
public MemberData(ulong id, DateTimeOffset? bannedUntil)
{
Id = id; Id = id;
BannedUntil = bannedUntil; BannedUntil = bannedUntil;
} }

View file

@ -3,25 +3,31 @@ using Remora.Results;
namespace Boyfriend.Data.Options; namespace Boyfriend.Data.Options;
public class BoolOption : Option<bool> { public sealed class BoolOption : Option<bool>
{
public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { } public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { }
public override string Display(JsonNode settings) { public override string Display(JsonNode settings)
{
return Get(settings) ? Messages.Yes : Messages.No; return Get(settings) ? Messages.Yes : Messages.No;
} }
public override Result Set(JsonNode settings, string from) { public override Result Set(JsonNode settings, string from)
{
if (!TryParseBool(from, out var value)) if (!TryParseBool(from, out var value))
{
return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue); return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = value; settings[Name] = value;
return Result.FromSuccess(); return Result.FromSuccess();
} }
private static bool TryParseBool(string from, out bool value) { private static bool TryParseBool(string from, out bool value)
from = from.ToLowerInvariant(); {
value = false; value = false;
switch (from) { switch (from.ToLowerInvariant())
{
case "true" or "1" or "y" or "yes" or "д" or "да": case "true" or "1" or "y" or "yes" or "д" or "да":
value = true; value = true;
return true; return true;

View file

@ -3,7 +3,8 @@ using Remora.Results;
namespace Boyfriend.Data.Options; namespace Boyfriend.Data.Options;
public interface IOption { public interface IOption
{
string Name { get; } string Name { get; }
string Display(JsonNode settings); string Display(JsonNode settings);
Result Set(JsonNode settings, string from); Result Set(JsonNode settings, string from);

View file

@ -6,8 +6,10 @@ using Remora.Results;
namespace Boyfriend.Data.Options; namespace Boyfriend.Data.Options;
/// <inheritdoc /> /// <inheritdoc />
public class LanguageOption : Option<CultureInfo> { public sealed class LanguageOption : Option<CultureInfo>
private static readonly Dictionary<string, CultureInfo> CultureInfoCache = new() { {
private static readonly Dictionary<string, CultureInfo> CultureInfoCache = new()
{
{ "en", new CultureInfo("en-US") }, { "en", new CultureInfo("en-US") },
{ "ru", new CultureInfo("ru-RU") }, { "ru", new CultureInfo("ru-RU") },
{ "mctaylors-ru", new CultureInfo("tt-RU") } { "mctaylors-ru", new CultureInfo("tt-RU") }
@ -15,18 +17,21 @@ public class LanguageOption : Option<CultureInfo> {
public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { } public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { }
public override string Display(JsonNode settings) { public override string Display(JsonNode settings)
{
return Markdown.InlineCode(settings[Name]?.GetValue<string>() ?? "en"); return Markdown.InlineCode(settings[Name]?.GetValue<string>() ?? "en");
} }
/// <inheritdoc /> /// <inheritdoc />
public override CultureInfo Get(JsonNode settings) { public override CultureInfo Get(JsonNode settings)
{
var property = settings[Name]; var property = settings[Name];
return property != null ? CultureInfoCache[property.GetValue<string>()] : DefaultValue; return property != null ? CultureInfoCache[property.GetValue<string>()] : DefaultValue;
} }
/// <inheritdoc /> /// <inheritdoc />
public override Result Set(JsonNode settings, string from) { public override Result Set(JsonNode settings, string from)
{
return CultureInfoCache.ContainsKey(from.ToLowerInvariant()) return CultureInfoCache.ContainsKey(from.ToLowerInvariant())
? base.Set(settings, from.ToLowerInvariant()) ? base.Set(settings, from.ToLowerInvariant())
: new ArgumentInvalidError(nameof(from), Messages.LanguageNotSupported); : new ArgumentInvalidError(nameof(from), Messages.LanguageNotSupported);

View file

@ -9,18 +9,21 @@ namespace Boyfriend.Data.Options;
/// </summary> /// </summary>
/// <typeparam name="T">The type of the option.</typeparam> /// <typeparam name="T">The type of the option.</typeparam>
public class Option<T> : IOption public class Option<T> : IOption
where T : notnull { where T : notnull
internal readonly T DefaultValue; {
protected readonly T DefaultValue;
public Option(string name, T defaultValue) { public Option(string name, T defaultValue)
{
Name = name; Name = name;
DefaultValue = defaultValue; DefaultValue = defaultValue;
} }
public string Name { get; } public string Name { get; }
public virtual string Display(JsonNode settings) { public virtual string Display(JsonNode settings)
return Markdown.InlineCode(Get(settings).ToString()!); {
return Markdown.InlineCode(Get(settings).ToString() ?? throw new InvalidOperationException());
} }
/// <summary> /// <summary>
@ -29,7 +32,8 @@ where T : notnull {
/// <param name="settings">The <see cref="JsonNode" /> to set the value to.</param> /// <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> /// <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> /// <returns>A value setting result which may or may not have succeeded.</returns>
public virtual Result Set(JsonNode settings, string from) { public virtual Result Set(JsonNode settings, string from)
{
settings[Name] = from; settings[Name] = from;
return Result.FromSuccess(); return Result.FromSuccess();
} }
@ -39,7 +43,8 @@ where T : notnull {
/// </summary> /// </summary>
/// <param name="settings">The <see cref="JsonNode" /> to get the value from.</param> /// <param name="settings">The <see cref="JsonNode" /> to get the value from.</param>
/// <returns>The value of the option.</returns> /// <returns>The value of the option.</returns>
public virtual T Get(JsonNode settings) { public virtual T Get(JsonNode settings)
{
var property = settings[Name]; var property = settings[Name];
return property != null ? property.GetValue<T>() : DefaultValue; return property != null ? property.GetValue<T>() : DefaultValue;
} }

View file

@ -6,21 +6,29 @@ using Remora.Results;
namespace Boyfriend.Data.Options; namespace Boyfriend.Data.Options;
public partial class SnowflakeOption : Option<Snowflake> { public sealed partial class SnowflakeOption : Option<Snowflake>
{
public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { } public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { }
public override string Display(JsonNode settings) { public override string Display(JsonNode settings)
return Name.EndsWith("Channel") ? Mention.Channel(Get(settings)) : Mention.Role(Get(settings)); {
return Name.EndsWith("Channel", StringComparison.Ordinal)
? Mention.Channel(Get(settings))
: Mention.Role(Get(settings));
} }
public override Snowflake Get(JsonNode settings) { public override Snowflake Get(JsonNode settings)
{
var property = settings[Name]; var property = settings[Name];
return property != null ? property.GetValue<ulong>().ToSnowflake() : DefaultValue; return property != null ? property.GetValue<ulong>().ToSnowflake() : DefaultValue;
} }
public override Result Set(JsonNode settings, string from) { public override Result Set(JsonNode settings, string from)
{
if (!ulong.TryParse(NonNumbers().Replace(from, ""), out var parsed)) if (!ulong.TryParse(NonNumbers().Replace(from, ""), out var parsed))
{
return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue); return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = parsed; settings[Name] = parsed;
return Result.FromSuccess(); return Result.FromSuccess();

View file

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

View file

@ -1,6 +1,7 @@
namespace Boyfriend.Data; namespace Boyfriend.Data;
public struct Reminder { public struct Reminder
{
public DateTimeOffset At; public DateTimeOffset At;
public string Text; public string Text;
public ulong Channel; public ulong Channel;

View file

@ -6,8 +6,10 @@ namespace Boyfriend.Data;
/// Stores information about scheduled events. This information is not provided by the Discord API. /// Stores information about scheduled events. This information is not provided by the Discord API.
/// </summary> /// </summary>
/// <remarks>This information is stored on disk as a JSON file.</remarks> /// <remarks>This information is stored on disk as a JSON file.</remarks>
public class ScheduledEventData { public sealed class ScheduledEventData
public ScheduledEventData(GuildScheduledEventStatus status) { {
public ScheduledEventData(GuildScheduledEventStatus status)
{
Status = status; Status = status;
} }

View file

@ -14,14 +14,16 @@ using Remora.Results;
namespace Boyfriend; namespace Boyfriend;
public static class Extensions { public static class Extensions
{
/// <summary> /// <summary>
/// Adds a footer with the <paramref name="user" />'s avatar and tag (@username or username#0000). /// Adds a footer with the <paramref name="user" />'s avatar and tag (@username or username#0000).
/// </summary> /// </summary>
/// <param name="builder">The builder to add the footer to.</param> /// <param name="builder">The builder to add the footer to.</param>
/// <param name="user">The user whose tag and avatar to add.</param> /// <param name="user">The user whose tag and avatar to add.</param>
/// <returns>The builder with the added footer.</returns> /// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithUserFooter(this EmbedBuilder builder, IUser user) { public static EmbedBuilder WithUserFooter(this EmbedBuilder builder, IUser user)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256); var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256);
var avatarUrl = avatarUrlResult.IsSuccess var avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity.AbsoluteUri ? avatarUrlResult.Entity.AbsoluteUri
@ -36,7 +38,8 @@ public static class Extensions {
/// <param name="builder">The builder to add the footer to.</param> /// <param name="builder">The builder to add the footer to.</param>
/// <param name="user">The user that performed the action whose tag and avatar to use.</param> /// <param name="user">The user that performed the action whose tag and avatar to use.</param>
/// <returns>The builder with the added footer.</returns> /// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user) { public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256); var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256);
var avatarUrl = avatarUrlResult.IsSuccess var avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity.AbsoluteUri ? avatarUrlResult.Entity.AbsoluteUri
@ -54,9 +57,11 @@ public static class Extensions {
/// <param name="avatarSource">The user whose avatar to use in the small title.</param> /// <param name="avatarSource">The user whose avatar to use in the small title.</param>
/// <returns>The builder with the added small title in the author field.</returns> /// <returns>The builder with the added small title in the author field.</returns>
public static EmbedBuilder WithSmallTitle( public static EmbedBuilder WithSmallTitle(
this EmbedBuilder builder, string text, IUser? avatarSource = null) { this EmbedBuilder builder, string text, IUser? avatarSource = null)
{
Uri? avatarUrl = null; Uri? avatarUrl = null;
if (avatarSource is not null) { if (avatarSource is not null)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256); var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256);
avatarUrl = avatarUrlResult.IsSuccess avatarUrl = avatarUrlResult.IsSuccess
@ -74,7 +79,8 @@ public static class Extensions {
/// <param name="builder">The builder to add the footer to.</param> /// <param name="builder">The builder to add the footer to.</param>
/// <param name="guild">The guild whose name and icon to use.</param> /// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added footer.</returns> /// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild) { public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild)
{
var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256);
var iconUrl = iconUrlResult.IsSuccess var iconUrl = iconUrlResult.IsSuccess
? iconUrlResult.Entity.AbsoluteUri ? iconUrlResult.Entity.AbsoluteUri
@ -89,7 +95,8 @@ public static class Extensions {
/// <param name="builder">The builder to add the title to.</param> /// <param name="builder">The builder to add the title to.</param>
/// <param name="guild">The guild whose name and icon to use.</param> /// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added title.</returns> /// <returns>The builder with the added title.</returns>
public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild) { public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild)
{
var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256);
var iconUrl = iconUrlResult.IsSuccess var iconUrl = iconUrlResult.IsSuccess
? iconUrlResult.Entity.AbsoluteUri ? iconUrlResult.Entity.AbsoluteUri
@ -107,8 +114,12 @@ public static class Extensions {
/// <param name="imageHashOptional">The Optional containing the image hash.</param> /// <param name="imageHashOptional">The Optional containing the image hash.</param>
/// <returns>The builder with the added cover image.</returns> /// <returns>The builder with the added cover image.</returns>
public static EmbedBuilder WithEventCover( public static EmbedBuilder WithEventCover(
this EmbedBuilder builder, Snowflake eventId, Optional<IImageHash?> imageHashOptional) { this EmbedBuilder builder, Snowflake eventId, Optional<IImageHash?> imageHashOptional)
if (!imageHashOptional.IsDefined(out var imageHash)) return builder; {
if (!imageHashOptional.IsDefined(out var imageHash))
{
return builder;
}
var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024); var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024);
return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder; return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder;
@ -120,25 +131,31 @@ public static class Extensions {
/// </summary> /// </summary>
/// <param name="s">The string to sanitize.</param> /// <param name="s">The string to sanitize.</param>
/// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns> /// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns>
private static string SanitizeForBlockCode(this string s) { private static string SanitizeForBlockCode(this string s)
{
return s.Replace("```", "```"); return s.Replace("```", "```");
} }
/// <summary> /// <summary>
/// Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string to use Markdown Block Code formatting with a specified /// Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string to use Markdown Block Code
/// formatting with a specified
/// language for syntax highlighting. /// language for syntax highlighting.
/// </summary> /// </summary>
/// <param name="s">The string to sanitize and format.</param> /// <param name="s">The string to sanitize and format.</param>
/// <param name="language"></param> /// <param name="language"></param>
/// <returns>The sanitized string formatted to use Markdown Block Code with a specified /// <returns>
/// language for syntax highlighting.</returns> /// The sanitized string formatted to use Markdown Block Code with a specified
public static string InBlockCode(this string s, string language = "") { /// language for syntax highlighting.
/// </returns>
public static string InBlockCode(this string s, string language = "")
{
s = s.SanitizeForBlockCode(); s = s.SanitizeForBlockCode();
return return
$"```{language}\n{s.SanitizeForBlockCode()}{(s.EndsWith("`") || string.IsNullOrWhiteSpace(s) ? " " : "")}```"; $"```{language}\n{s.SanitizeForBlockCode()}{(s.EndsWith("`", StringComparison.Ordinal) || string.IsNullOrWhiteSpace(s) ? " " : "")}```";
} }
public static string Localized(this string key) { public static string Localized(this string key)
{
return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key; return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key;
} }
@ -148,41 +165,56 @@ public static class Extensions {
/// <remarks>Used when encountering "Request headers must contain only ASCII characters".</remarks> /// <remarks>Used when encountering "Request headers must contain only ASCII characters".</remarks>
/// <param name="s">The string to encode.</param> /// <param name="s">The string to encode.</param>
/// <returns>An encoded string with spaces kept intact.</returns> /// <returns>An encoded string with spaces kept intact.</returns>
public static string EncodeHeader(this string s) { public static string EncodeHeader(this string s)
{
return WebUtility.UrlEncode(s).Replace('+', ' '); return WebUtility.UrlEncode(s).Replace('+', ' ');
} }
public static string AsMarkdown(this DiffPaneModel model) { public static string AsMarkdown(this DiffPaneModel model)
{
var builder = new StringBuilder(); var builder = new StringBuilder();
foreach (var line in model.Lines) { foreach (var line in model.Lines)
{
if (line.Type is ChangeType.Deleted) if (line.Type is ChangeType.Deleted)
{
builder.Append("-- "); builder.Append("-- ");
}
if (line.Type is ChangeType.Inserted) if (line.Type is ChangeType.Inserted)
{
builder.Append("++ "); builder.Append("++ ");
}
if (line.Type is not ChangeType.Imaginary) if (line.Type is not ChangeType.Imaginary)
{
builder.AppendLine(line.Text); builder.AppendLine(line.Text);
} }
}
return InBlockCode(builder.ToString(), "diff"); return InBlockCode(builder.ToString(), "diff");
} }
public static string GetTag(this IUser user) { public static string GetTag(this IUser user)
{
return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}"; return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}";
} }
public static Snowflake ToSnowflake(this ulong id) { public static Snowflake ToSnowflake(this ulong id)
{
return DiscordSnowflake.New(id); return DiscordSnowflake.New(id);
} }
public static TResult? MaxOrDefault<TSource, TResult>( public static TResult? MaxOrDefault<TSource, TResult>(
this IEnumerable<TSource> source, Func<TSource, TResult> selector) { this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
var list = source.ToList(); var list = source.ToList();
return list.Any() ? list.Max(selector) : default; return list.Any() ? list.Max(selector) : default;
} }
public static bool TryGetContextIDs( public static bool TryGetContextIDs(
this ICommandContext context, out Snowflake guildId, this ICommandContext context, out Snowflake guildId,
out Snowflake channelId, out Snowflake userId) { out Snowflake channelId, out Snowflake userId)
{
channelId = default; channelId = default;
userId = default; userId = default;
return context.TryGetGuildID(out guildId) return context.TryGetGuildID(out guildId)
@ -195,7 +227,8 @@ public static class Extensions {
/// </summary> /// </summary>
/// <param name="snowflake">The Snowflake to check.</param> /// <param name="snowflake">The Snowflake to check.</param>
/// <returns>true if the Snowflake has no value set or it's set to 0, false otherwise.</returns> /// <returns>true if the Snowflake has no value set or it's set to 0, false otherwise.</returns>
public static bool Empty(this Snowflake snowflake) { public static bool Empty(this Snowflake snowflake)
{
return snowflake.Value is 0; return snowflake.Value is 0;
} }
@ -210,14 +243,18 @@ public static class Extensions {
/// otherwise. /// otherwise.
/// </returns> /// </returns>
/// <seealso cref="Empty" /> /// <seealso cref="Empty" />
public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake) { public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake)
{
return snowflake.Empty() || snowflake == anotherSnowflake; return snowflake.Empty() || snowflake == anotherSnowflake;
} }
public static async Task<Result> SendContextualEmbedResultAsync( public static async Task<Result> SendContextualEmbedResultAsync(
this FeedbackService feedback, Result<Embed> embedResult, CancellationToken ct = default) { this FeedbackService feedback, Result<Embed> embedResult, CancellationToken ct = default)
{
if (!embedResult.IsDefined(out var embed)) if (!embedResult.IsDefined(out var embed))
{
return Result.FromError(embedResult); return Result.FromError(embedResult);
}
return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct); return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct);
} }

View file

@ -11,11 +11,13 @@ namespace Boyfriend;
/// Handles responding to various interactions. /// Handles responding to various interactions.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class InteractionResponders : InteractionGroup { public class InteractionResponders : InteractionGroup
private readonly FeedbackService _feedbackService; {
private readonly FeedbackService _feedback;
public InteractionResponders(FeedbackService feedbackService) { public InteractionResponders(FeedbackService feedback)
_feedbackService = feedbackService; {
_feedback = feedback;
} }
/// <summary> /// <summary>
@ -25,11 +27,15 @@ public class InteractionResponders : InteractionGroup {
/// <returns>An ephemeral feedback sending result which may or may not have succeeded.</returns> /// <returns>An ephemeral feedback sending result which may or may not have succeeded.</returns>
[Button("scheduled-event-details")] [Button("scheduled-event-details")]
[UsedImplicitly] [UsedImplicitly]
public async Task<Result> OnStatefulButtonClicked(string? state = null) { public async Task<Result> OnStatefulButtonClicked(string? state = null)
if (state is null) return new ArgumentNullError(nameof(state)); {
if (state is null)
{
return new ArgumentNullError(nameof(state));
}
var idArray = state.Split(':'); var idArray = state.Split(':');
return (Result)await _feedbackService.SendContextualAsync( return (Result)await _feedback.SendContextualAsync(
$"https://discord.com/events/{idArray[0]}/{idArray[1]}", $"https://discord.com/events/{idArray[0]}/{idArray[1]}",
options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral), ct: CancellationToken); options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral), ct: CancellationToken);
} }

View file

@ -16,35 +16,49 @@ namespace Boyfriend.Responders;
/// has <see cref="GuildSettings.ReceiveStartupMessages" /> enabled /// has <see cref="GuildSettings.ReceiveStartupMessages" /> enabled
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class GuildLoadedResponder : IResponder<IGuildCreate> { public class GuildLoadedResponder : IResponder<IGuildCreate>
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService; private readonly GuildDataService _guildData;
private readonly ILogger<GuildLoadedResponder> _logger; private readonly ILogger<GuildLoadedResponder> _logger;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public GuildLoadedResponder( public GuildLoadedResponder(
IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger<GuildLoadedResponder> logger, IDiscordRestChannelAPI channelApi, GuildDataService guildData, ILogger<GuildLoadedResponder> logger,
IDiscordRestUserAPI userApi) { IDiscordRestUserAPI userApi)
{
_channelApi = channelApi; _channelApi = channelApi;
_dataService = dataService; _guildData = guildData;
_logger = logger; _logger = logger;
_userApi = userApi; _userApi = userApi;
} }
public async Task<Result> RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) { public async Task<Result> RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default)
if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild is not IAvailableGuild {
if (!gatewayEvent.Guild.IsT0) // Guild is not IAvailableGuild
{
return Result.FromSuccess();
}
var guild = gatewayEvent.Guild.AsT0; var guild = gatewayEvent.Guild.AsT0;
_logger.LogInformation("Joined guild \"{Name}\"", guild.Name); _logger.LogInformation("Joined guild \"{Name}\"", guild.Name);
var cfg = await _dataService.GetSettings(guild.ID, ct); var cfg = await _guildData.GetSettings(guild.ID, ct);
if (!GuildSettings.ReceiveStartupMessages.Get(cfg)) if (!GuildSettings.ReceiveStartupMessages.Get(cfg))
{
return Result.FromSuccess(); return Result.FromSuccess();
}
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.FromSuccess(); return Result.FromSuccess();
}
var currentUserResult = await _userApi.GetCurrentUserAsync(ct); var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult);
}
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
var i = Random.Shared.Next(1, 4); var i = Random.Shared.Next(1, 4);
@ -55,7 +69,10 @@ public class GuildLoadedResponder : IResponder<IGuildCreate> {
.WithCurrentTimestamp() .WithCurrentTimestamp()
.WithColour(ColorsList.Blue) .WithColour(ColorsList.Blue)
.Build(); .Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed); if (!embed.IsDefined(out var built))
{
return Result.FromError(embed);
}
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct); GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct);

View file

@ -15,31 +15,44 @@ namespace Boyfriend.Responders;
/// </summary> /// </summary>
/// <seealso cref="GuildSettings.WelcomeMessage" /> /// <seealso cref="GuildSettings.WelcomeMessage" />
[UsedImplicitly] [UsedImplicitly]
public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd> { public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
public GuildMemberJoinedResponder( public GuildMemberJoinedResponder(
IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) { IDiscordRestChannelAPI channelApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi)
{
_channelApi = channelApi; _channelApi = channelApi;
_dataService = dataService; _guildData = guildData;
_guildApi = guildApi; _guildApi = guildApi;
} }
public async Task<Result> RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) { public async Task<Result> RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.User.IsDefined(out var user)) if (!gatewayEvent.User.IsDefined(out var user))
{
return new ArgumentNullError(nameof(gatewayEvent.User)); return new ArgumentNullError(nameof(gatewayEvent.User));
var data = await _dataService.GetData(gatewayEvent.GuildID, ct); }
var data = await _guildData.GetData(gatewayEvent.GuildID, ct);
var cfg = data.Settings; var cfg = data.Settings;
if (GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() if (GuildSettings.PublicFeedbackChannel.Get(cfg).Empty()
|| GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled") || GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled")
{
return Result.FromSuccess(); return Result.FromSuccess();
if (GuildSettings.ReturnRolesOnRejoin.Get(cfg)) { }
if (GuildSettings.ReturnRolesOnRejoin.Get(cfg))
{
var result = await _guildApi.ModifyGuildMemberAsync( var result = await _guildApi.ModifyGuildMemberAsync(
gatewayEvent.GuildID, user.ID, gatewayEvent.GuildID, user.ID,
roles: data.GetMemberData(user.ID).Roles.ConvertAll(r => r.ToSnowflake()), ct: ct); roles: data.GetMemberData(user.ID).Roles.ConvertAll(r => r.ToSnowflake()), ct: ct);
if (!result.IsSuccess) return Result.FromError(result.Error); if (!result.IsSuccess)
{
return Result.FromError(result.Error);
}
} }
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
@ -48,7 +61,10 @@ public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd> {
: GuildSettings.WelcomeMessage.Get(cfg); : GuildSettings.WelcomeMessage.Get(cfg);
var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult); if (!guildResult.IsDefined(out var guild))
{
return Result.FromError(guildResult);
}
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user) .WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user)
@ -56,7 +72,10 @@ public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd> {
.WithTimestamp(gatewayEvent.JoinedAt) .WithTimestamp(gatewayEvent.JoinedAt)
.WithColour(ColorsList.Green) .WithColour(ColorsList.Green)
.Build(); .Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed); if (!embed.IsDefined(out var built))
{
return Result.FromError(embed);
}
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: new[] { built }, GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: new[] { built },

View file

@ -11,15 +11,18 @@ namespace Boyfriend.Responders;
/// Handles updating <see cref="MemberData.Roles" /> when a guild member is updated. /// Handles updating <see cref="MemberData.Roles" /> when a guild member is updated.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class GuildMemberUpdateResponder : IResponder<IGuildMemberUpdate> { public class GuildMemberUpdateResponder : IResponder<IGuildMemberUpdate>
private readonly GuildDataService _dataService; {
private readonly GuildDataService _guildData;
public GuildMemberUpdateResponder(GuildDataService dataService) { public GuildMemberUpdateResponder(GuildDataService guildData)
_dataService = dataService; {
_guildData = guildData;
} }
public async Task<Result> RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) { public async Task<Result> RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default)
var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct); {
var memberData = await _guildData.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct);
memberData.Roles = gatewayEvent.Roles.ToList().ConvertAll(r => r.Value); memberData.Roles = gatewayEvent.Roles.ToList().ConvertAll(r => r.Value);
return Result.FromSuccess(); return Result.FromSuccess();
} }

View file

@ -16,43 +16,68 @@ namespace Boyfriend.Responders;
/// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set. /// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class MessageDeletedResponder : IResponder<IMessageDelete> { public class MessageDeletedResponder : IResponder<IMessageDelete>
{
private readonly IDiscordRestAuditLogAPI _auditLogApi; private readonly IDiscordRestAuditLogAPI _auditLogApi;
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public MessageDeletedResponder( public MessageDeletedResponder(
IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi, IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi,
GuildDataService dataService, IDiscordRestUserAPI userApi) { GuildDataService guildData, IDiscordRestUserAPI userApi)
{
_auditLogApi = auditLogApi; _auditLogApi = auditLogApi;
_channelApi = channelApi; _channelApi = channelApi;
_dataService = dataService; _guildData = guildData;
_userApi = userApi; _userApi = userApi;
} }
public async Task<Result> RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) { public async Task<Result> RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default)
if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess(); {
if (!gatewayEvent.GuildID.IsDefined(out var guildId))
{
return Result.FromSuccess();
}
var cfg = await _dataService.GetSettings(guildId, ct); var cfg = await _guildData.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) return Result.FromSuccess(); if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.FromSuccess();
}
var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult); if (!messageResult.IsDefined(out var message))
if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess(); {
return Result.FromError(messageResult);
}
if (string.IsNullOrWhiteSpace(message.Content))
{
return Result.FromSuccess();
}
var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync( var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct); guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult); if (!auditLogResult.IsDefined(out var auditLogPage))
{
return Result.FromError(auditLogResult);
}
var auditLog = auditLogPage.AuditLogEntries.Single(); var auditLog = auditLogPage.AuditLogEntries.Single();
var userResult = Result<IUser>.FromSuccess(message.Author); var userResult = Result<IUser>.FromSuccess(message.Author);
if (auditLog.Options.Value.ChannelID == gatewayEvent.ChannelID if (auditLog.UserID is not null
&& auditLog.Options.Value.ChannelID == gatewayEvent.ChannelID
&& DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) && DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2)
userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct); {
userResult = await _userApi.GetUserAsync(auditLog.UserID.Value, ct);
}
if (!userResult.IsDefined(out var user)) return Result.FromError(userResult); if (!userResult.IsDefined(out var user))
{
return Result.FromError(userResult);
}
Messages.Culture = GuildSettings.Language.Get(cfg); Messages.Culture = GuildSettings.Language.Get(cfg);
@ -67,7 +92,10 @@ public class MessageDeletedResponder : IResponder<IMessageDelete> {
.WithTimestamp(message.Timestamp) .WithTimestamp(message.Timestamp)
.WithColour(ColorsList.Red) .WithColour(ColorsList.Red)
.Build(); .Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed); if (!embed.IsDefined(out var built))
{
return Result.FromError(embed);
}
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built },

View file

@ -18,42 +18,68 @@ namespace Boyfriend.Responders;
/// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set. /// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class MessageEditedResponder : IResponder<IMessageUpdate> { public class MessageEditedResponder : IResponder<IMessageUpdate>
{
private readonly CacheService _cacheService; private readonly CacheService _cacheService;
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService; private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
public MessageEditedResponder( public MessageEditedResponder(
CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService, CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService guildData,
IDiscordRestUserAPI userApi) { IDiscordRestUserAPI userApi)
{
_cacheService = cacheService; _cacheService = cacheService;
_channelApi = channelApi; _channelApi = channelApi;
_dataService = dataService; _guildData = guildData;
_userApi = userApi; _userApi = userApi;
} }
public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) { public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.GuildID.IsDefined(out var guildId)) if (!gatewayEvent.GuildID.IsDefined(out var guildId))
{
return Result.FromSuccess(); return Result.FromSuccess();
var cfg = await _dataService.GetSettings(guildId, ct); }
var cfg = await _guildData.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.FromSuccess(); return Result.FromSuccess();
}
if (!gatewayEvent.Content.IsDefined(out var newContent)) if (!gatewayEvent.Content.IsDefined(out var newContent))
{
return Result.FromSuccess(); return Result.FromSuccess();
}
if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp)) if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp))
{
return Result.FromSuccess(); // The message wasn't actually edited return Result.FromSuccess(); // The message wasn't actually edited
}
if (!gatewayEvent.ChannelID.IsDefined(out var channelId)) if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
{
return new ArgumentNullError(nameof(gatewayEvent.ChannelID)); return new ArgumentNullError(nameof(gatewayEvent.ChannelID));
}
if (!gatewayEvent.ID.IsDefined(out var messageId)) if (!gatewayEvent.ID.IsDefined(out var messageId))
{
return new ArgumentNullError(nameof(gatewayEvent.ID)); return new ArgumentNullError(nameof(gatewayEvent.ID));
}
var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId); var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
var messageResult = await _cacheService.TryGetValueAsync<IMessage>( var messageResult = await _cacheService.TryGetValueAsync<IMessage>(
cacheKey, ct); cacheKey, ct);
if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult); if (!messageResult.IsDefined(out var message))
if (message.Content == newContent) return Result.FromSuccess(); {
return Result.FromError(messageResult);
}
if (message.Content == newContent)
{
return Result.FromSuccess();
}
// Custom event responders are called earlier than responders responsible for message caching // Custom event responders are called earlier than responders responsible for message caching
// This means that subsequent edit logs may contain the wrong content // This means that subsequent edit logs may contain the wrong content
@ -67,7 +93,10 @@ public class MessageEditedResponder : IResponder<IMessageUpdate> {
_ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct); _ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
var currentUserResult = await _userApi.GetCurrentUserAsync(ct); var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult);
}
var diff = InlineDiffBuilder.Diff(message.Content, newContent); var diff = InlineDiffBuilder.Diff(message.Content, newContent);
@ -80,7 +109,10 @@ public class MessageEditedResponder : IResponder<IMessageUpdate> {
.WithTimestamp(timestamp.Value) .WithTimestamp(timestamp.Value)
.WithColour(ColorsList.Yellow) .WithColour(ColorsList.Yellow)
.Build(); .Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed); if (!embed.IsDefined(out var built))
{
return Result.FromError(embed);
}
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built },

View file

@ -11,16 +11,20 @@ namespace Boyfriend.Responders;
/// Handles sending replies to easter egg messages. /// Handles sending replies to easter egg messages.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class MessageCreateResponder : IResponder<IMessageCreate> { public class MessageCreateResponder : IResponder<IMessageCreate>
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
public MessageCreateResponder(IDiscordRestChannelAPI channelApi) { public MessageCreateResponder(IDiscordRestChannelAPI channelApi)
{
_channelApi = channelApi; _channelApi = channelApi;
} }
public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) { public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default)
{
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content.ToLowerInvariant() switch { gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content.ToLowerInvariant() switch
{
"whoami" => "`nobody`", "whoami" => "`nobody`",
"сука !!" => "`root`", "сука !!" => "`root`",
"воооо" => "`removing /...`", "воооо" => "`removing /...`",

View file

@ -14,21 +14,26 @@ namespace Boyfriend.Responders;
/// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set. /// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary> /// </summary>
[UsedImplicitly] [UsedImplicitly]
public class GuildScheduledEventDeleteResponder : IResponder<IGuildScheduledEventDelete> { public class GuildScheduledEventDeleteResponder : IResponder<IGuildScheduledEventDelete>
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService; private readonly GuildDataService _guildData;
public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) { public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService guildData)
{
_channelApi = channelApi; _channelApi = channelApi;
_dataService = dataService; _guildData = guildData;
} }
public async Task<Result> RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) { public async Task<Result> RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default)
var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct); {
var guildData = await _guildData.GetData(gatewayEvent.GuildID, ct);
guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value); guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value);
if (GuildSettings.EventNotificationChannel.Get(guildData.Settings).Empty()) if (GuildSettings.EventNotificationChannel.Get(guildData.Settings).Empty())
{
return Result.FromSuccess(); return Result.FromSuccess();
}
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name)) .WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name))
@ -37,7 +42,10 @@ public class GuildScheduledEventDeleteResponder : IResponder<IGuildScheduledEven
.WithCurrentTimestamp() .WithCurrentTimestamp()
.Build(); .Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed); if (!embed.IsDefined(out var built))
{
return Result.FromError(embed);
}
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(guildData.Settings), embeds: new[] { built }, ct: ct); GuildSettings.EventNotificationChannel.Get(guildData.Settings), embeds: new[] { built }, ct: ct);

View file

@ -11,39 +11,47 @@ namespace Boyfriend.Services;
/// <summary> /// <summary>
/// Handles saving, loading, initializing and providing <see cref="GuildData" />. /// Handles saving, loading, initializing and providing <see cref="GuildData" />.
/// </summary> /// </summary>
public class GuildDataService : IHostedService { public sealed class GuildDataService : IHostedService
{
private readonly ConcurrentDictionary<Snowflake, GuildData> _datas = new(); private readonly ConcurrentDictionary<Snowflake, GuildData> _datas = new();
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
// https://github.com/dotnet/aspnetcore/issues/39139 // https://github.com/dotnet/aspnetcore/issues/39139
public GuildDataService( public GuildDataService(
IHostApplicationLifetime lifetime, IDiscordRestGuildAPI guildApi) { IHostApplicationLifetime lifetime, IDiscordRestGuildAPI guildApi)
{
_guildApi = guildApi; _guildApi = guildApi;
lifetime.ApplicationStopping.Register(ApplicationStopping); lifetime.ApplicationStopping.Register(ApplicationStopping);
} }
public Task StartAsync(CancellationToken ct) { public Task StartAsync(CancellationToken ct)
{
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task StopAsync(CancellationToken ct) { public Task StopAsync(CancellationToken ct)
{
return Task.CompletedTask; return Task.CompletedTask;
} }
private void ApplicationStopping() { private void ApplicationStopping()
{
SaveAsync(CancellationToken.None).GetAwaiter().GetResult(); SaveAsync(CancellationToken.None).GetAwaiter().GetResult();
} }
private async Task SaveAsync(CancellationToken ct) { private async Task SaveAsync(CancellationToken ct)
{
var tasks = new List<Task>(); var tasks = new List<Task>();
foreach (var data in _datas.Values) { foreach (var data in _datas.Values)
{
await using var settingsStream = File.OpenWrite(data.SettingsPath); await using var settingsStream = File.OpenWrite(data.SettingsPath);
tasks.Add(JsonSerializer.SerializeAsync(settingsStream, data.Settings, cancellationToken: ct)); tasks.Add(JsonSerializer.SerializeAsync(settingsStream, data.Settings, cancellationToken: ct));
await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath); await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath);
tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct)); tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct));
foreach (var memberData in data.MemberData.Values) { foreach (var memberData in data.MemberData.Values)
{
await using var memberDataStream = File.OpenWrite($"{data.MemberDataPath}/{memberData.Id}.json"); await using var memberDataStream = File.OpenWrite($"{data.MemberDataPath}/{memberData.Id}.json");
tasks.Add(JsonSerializer.SerializeAsync(memberDataStream, memberData, cancellationToken: ct)); tasks.Add(JsonSerializer.SerializeAsync(memberDataStream, memberData, cancellationToken: ct));
} }
@ -52,19 +60,36 @@ public class GuildDataService : IHostedService {
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
} }
public async Task<GuildData> GetData(Snowflake guildId, CancellationToken ct = default) { public async Task<GuildData> GetData(Snowflake guildId, CancellationToken ct = default)
{
return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct); return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct);
} }
private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default) { private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default)
{
var idString = $"{guildId}"; var idString = $"{guildId}";
var memberDataPath = $"{guildId}/MemberData"; var memberDataPath = $"{guildId}/MemberData";
var settingsPath = $"{guildId}/Settings.json"; var settingsPath = $"{guildId}/Settings.json";
var scheduledEventsPath = $"{guildId}/ScheduledEvents.json"; var scheduledEventsPath = $"{guildId}/ScheduledEvents.json";
if (!Directory.Exists(idString)) Directory.CreateDirectory(idString); if (!Directory.Exists(idString))
if (!Directory.Exists(memberDataPath)) Directory.CreateDirectory(memberDataPath); {
if (!File.Exists(settingsPath)) await File.WriteAllTextAsync(settingsPath, "{}", ct); Directory.CreateDirectory(idString);
if (!File.Exists(scheduledEventsPath)) await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct); }
if (!Directory.Exists(memberDataPath))
{
Directory.CreateDirectory(memberDataPath);
}
if (!File.Exists(settingsPath))
{
await File.WriteAllTextAsync(settingsPath, "{}", ct);
}
if (!File.Exists(scheduledEventsPath))
{
await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
}
await using var settingsStream = File.OpenRead(settingsPath); await using var settingsStream = File.OpenRead(settingsPath);
var jsonSettings var jsonSettings
@ -76,13 +101,20 @@ public class GuildDataService : IHostedService {
eventsStream, cancellationToken: ct); eventsStream, cancellationToken: ct);
var memberData = new Dictionary<ulong, MemberData>(); var memberData = new Dictionary<ulong, MemberData>();
foreach (var dataPath in Directory.GetFiles(memberDataPath)) { foreach (var dataPath in Directory.GetFiles(memberDataPath))
{
await using var dataStream = File.OpenRead(dataPath); await using var dataStream = File.OpenRead(dataPath);
var data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct); var data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
if (data is null) continue; if (data is null)
{
continue;
}
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToSnowflake(), ct); var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToSnowflake(), ct);
if (memberResult.IsSuccess) if (memberResult.IsSuccess)
{
data.Roles = memberResult.Entity.Roles.ToList().ConvertAll(r => r.Value); data.Roles = memberResult.Entity.Roles.ToList().ConvertAll(r => r.Value);
}
memberData.Add(data.Id, data); memberData.Add(data.Id, data);
} }
@ -91,19 +123,26 @@ public class GuildDataService : IHostedService {
jsonSettings ?? new JsonObject(), settingsPath, jsonSettings ?? new JsonObject(), settingsPath,
await events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath, await events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
memberData, memberDataPath); memberData, memberDataPath);
while (!_datas.ContainsKey(guildId)) _datas.TryAdd(guildId, finalData); while (!_datas.ContainsKey(guildId))
{
_datas.TryAdd(guildId, finalData);
}
return finalData; return finalData;
} }
public async Task<JsonNode> GetSettings(Snowflake guildId, CancellationToken ct = default) { public async Task<JsonNode> GetSettings(Snowflake guildId, CancellationToken ct = default)
{
return (await GetData(guildId, ct)).Settings; return (await GetData(guildId, ct)).Settings;
} }
public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) { public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default)
{
return (await GetData(guildId, ct)).GetMemberData(userId); return (await GetData(guildId, ct)).GetMemberData(userId);
} }
public ICollection<Snowflake> GetGuildIds() { public ICollection<Snowflake> GetGuildIds()
{
return _datas.Keys; return _datas.Keys;
} }
} }

View file

@ -20,8 +20,10 @@ namespace Boyfriend.Services;
/// <summary> /// <summary>
/// Handles executing guild updates (also called "ticks") once per second. /// Handles executing guild updates (also called "ticks") once per second.
/// </summary> /// </summary>
public partial class GuildUpdateService : BackgroundService { public sealed partial class GuildUpdateService : BackgroundService
private static readonly (string Name, TimeSpan Duration)[] SongList = { {
private static readonly (string Name, TimeSpan Duration)[] SongList =
{
("UNDEAD CORPORATION - The Empress", new TimeSpan(0, 4, 34)), ("UNDEAD CORPORATION - The Empress", new TimeSpan(0, 4, 34)),
("UNDEAD CORPORATION - Everything will freeze", new TimeSpan(0, 3, 17)), ("UNDEAD CORPORATION - Everything will freeze", new TimeSpan(0, 3, 17)),
("Splatoon 3 - Rockagilly Blues (Yoko & the Gold Bazookas)", new TimeSpan(0, 3, 37)), ("Splatoon 3 - Rockagilly Blues (Yoko & the Gold Bazookas)", new TimeSpan(0, 3, 37)),
@ -36,7 +38,8 @@ public partial class GuildUpdateService : BackgroundService {
("Noisecream - Mist of Rage", new TimeSpan(0, 2, 25)) ("Noisecream - Mist of Rage", new TimeSpan(0, 2, 25))
}; };
private static readonly string[] GenericNicknames = { private static readonly string[] GenericNicknames =
{
"Albatross", "Alpha", "Anchor", "Banjo", "Bell", "Beta", "Blackbird", "Bulldog", "Canary", "Albatross", "Alpha", "Anchor", "Banjo", "Bell", "Beta", "Blackbird", "Bulldog", "Canary",
"Cat", "Calf", "Cyclone", "Daisy", "Dalmatian", "Dart", "Delta", "Diamond", "Donkey", "Duck", "Cat", "Calf", "Cyclone", "Daisy", "Dalmatian", "Dart", "Delta", "Diamond", "Donkey", "Duck",
"Emu", "Eclipse", "Flamingo", "Flute", "Frog", "Goose", "Hatchet", "Heron", "Husky", "Hurricane", "Emu", "Eclipse", "Flamingo", "Flute", "Frog", "Goose", "Hatchet", "Heron", "Husky", "Hurricane",
@ -50,9 +53,9 @@ public partial class GuildUpdateService : BackgroundService {
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client; private readonly DiscordGatewayClient _client;
private readonly GuildDataService _dataService;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi; private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly ILogger<GuildUpdateService> _logger; private readonly ILogger<GuildUpdateService> _logger;
private readonly IDiscordRestUserAPI _userApi; private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility; private readonly UtilityService _utility;
@ -61,12 +64,13 @@ public partial class GuildUpdateService : BackgroundService {
private uint _nextSongIndex; private uint _nextSongIndex;
public GuildUpdateService( public GuildUpdateService(
IDiscordRestChannelAPI channelApi, DiscordGatewayClient client, GuildDataService dataService, IDiscordRestChannelAPI channelApi, DiscordGatewayClient client, GuildDataService guildData,
IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, ILogger<GuildUpdateService> logger, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, ILogger<GuildUpdateService> logger,
IDiscordRestUserAPI userApi, UtilityService utility) { IDiscordRestUserAPI userApi, UtilityService utility)
{
_channelApi = channelApi; _channelApi = channelApi;
_client = client; _client = client;
_dataService = dataService; _guildData = guildData;
_eventApi = eventApi; _eventApi = eventApi;
_guildApi = guildApi; _guildApi = guildApi;
_logger = logger; _logger = logger;
@ -76,17 +80,20 @@ public partial class GuildUpdateService : BackgroundService {
/// <summary> /// <summary>
/// Activates a periodic timer with a 1 second interval and adds guild update tasks on each timer tick. /// Activates a periodic timer with a 1 second interval and adds guild update tasks on each timer tick.
/// Additionally, updates the current presence with songs from <see cref="SongList"/>. /// Additionally, updates the current presence with songs from <see cref="SongList" />.
/// </summary> /// </summary>
/// <remarks>If update tasks take longer than 1 second, the next timer tick will be skipped.</remarks> /// <remarks>If update tasks take longer than 1 second, the next timer tick will be skipped.</remarks>
/// <param name="ct">The cancellation token for this operation.</param> /// <param name="ct">The cancellation token for this operation.</param>
protected override async Task ExecuteAsync(CancellationToken ct) { protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
var tasks = new List<Task>(); var tasks = new List<Task>();
while (await timer.WaitForNextTickAsync(ct)) { while (await timer.WaitForNextTickAsync(ct))
var guildIds = _dataService.GetGuildIds(); {
if (guildIds.Count > 0 && DateTimeOffset.UtcNow >= _nextSongAt) { var guildIds = _guildData.GetGuildIds();
if (guildIds.Count > 0 && DateTimeOffset.UtcNow >= _nextSongAt)
{
var nextSong = SongList[_nextSongIndex]; var nextSong = SongList[_nextSongIndex];
_activityList[0] = new Activity(nextSong.Name, ActivityType.Listening); _activityList[0] = new Activity(nextSong.Name, ActivityType.Listening);
_client.SubmitCommand( _client.SubmitCommand(
@ -94,7 +101,10 @@ public partial class GuildUpdateService : BackgroundService {
UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList)); UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList));
_nextSongAt = DateTimeOffset.UtcNow.Add(nextSong.Duration); _nextSongAt = DateTimeOffset.UtcNow.Add(nextSong.Duration);
_nextSongIndex++; _nextSongIndex++;
if (_nextSongIndex >= SongList.Length) _nextSongIndex = 0; if (_nextSongIndex >= SongList.Length)
{
_nextSongIndex = 0;
}
} }
tasks.AddRange(guildIds.Select(id => TickGuildAsync(id, ct))); tasks.AddRange(guildIds.Select(id => TickGuildAsync(id, ct)));
@ -111,9 +121,9 @@ public partial class GuildUpdateService : BackgroundService {
/// This method does the following: /// This method does the following:
/// <list type="bullet"> /// <list type="bullet">
/// <item>Automatically unbans users once their ban period has expired.</item> /// <item>Automatically unbans users once their ban period has expired.</item>
/// <item>Automatically grants members the guild's <see cref="GuildSettings.DefaultRole"/> if one is set.</item> /// <item>Automatically grants members the guild's <see cref="GuildSettings.DefaultRole" /> if one is set.</item>
/// <item>Sends reminders about an upcoming scheduled event.</item> /// <item>Sends reminders about an upcoming scheduled event.</item>
/// <item>Automatically starts scheduled events if <see cref="GuildSettings.AutoStartEvents"/> is enabled.</item> /// <item>Automatically starts scheduled events if <see cref="GuildSettings.AutoStartEvents" /> is enabled.</item>
/// <item>Sends scheduled event start notifications.</item> /// <item>Sends scheduled event start notifications.</item>
/// <item>Sends scheduled event completion notifications.</item> /// <item>Sends scheduled event completion notifications.</item>
/// <item>Sends reminders to members.</item> /// <item>Sends reminders to members.</item>
@ -129,41 +139,62 @@ public partial class GuildUpdateService : BackgroundService {
/// </remarks> /// </remarks>
/// <param name="guildId">The ID of the guild to update.</param> /// <param name="guildId">The ID of the guild to update.</param>
/// <param name="ct">The cancellation token for this operation.</param> /// <param name="ct">The cancellation token for this operation.</param>
private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) { private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default)
var data = await _dataService.GetData(guildId, ct); {
var data = await _guildData.GetData(guildId, ct);
Messages.Culture = GuildSettings.Language.Get(data.Settings); Messages.Culture = GuildSettings.Language.Get(data.Settings);
var defaultRole = GuildSettings.DefaultRole.Get(data.Settings); var defaultRole = GuildSettings.DefaultRole.Get(data.Settings);
foreach (var memberData in data.MemberData.Values) { foreach (var memberData in data.MemberData.Values)
{
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, memberData.Id.ToSnowflake(), ct); var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, memberData.Id.ToSnowflake(), ct);
if (!guildMemberResult.IsDefined(out var guildMember)) return; if (!guildMemberResult.IsDefined(out var guildMember))
if (!guildMember.User.IsDefined(out var user)) return; {
return;
}
if (!guildMember.User.IsDefined(out var user))
{
return;
}
await TickMemberAsync(guildId, user, guildMember, memberData, defaultRole, data.Settings, ct); await TickMemberAsync(guildId, user, guildMember, memberData, defaultRole, data.Settings, ct);
} }
var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
if (!eventsResult.IsSuccess) if (!eventsResult.IsSuccess)
{
_logger.LogWarning("Error retrieving scheduled events.\n{ErrorMessage}", eventsResult.Error.Message); _logger.LogWarning("Error retrieving scheduled events.\n{ErrorMessage}", eventsResult.Error.Message);
else if (!GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) return;
}
if (!GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
await TickScheduledEventsAsync(guildId, data, eventsResult.Entity, ct); await TickScheduledEventsAsync(guildId, data, eventsResult.Entity, ct);
} }
}
private async Task TickScheduledEventsAsync( private async Task TickScheduledEventsAsync(
Snowflake guildId, GuildData data, IEnumerable<IGuildScheduledEvent> events, CancellationToken ct) { Snowflake guildId, GuildData data, IEnumerable<IGuildScheduledEvent> events, CancellationToken ct)
foreach (var scheduledEvent in events) { {
foreach (var scheduledEvent in events)
{
if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value))
{
data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status)); data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status));
}
var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value];
if (storedEvent.Status == scheduledEvent.Status) { if (storedEvent.Status == scheduledEvent.Status)
{
await TickScheduledEventAsync(guildId, data, scheduledEvent, storedEvent, ct); await TickScheduledEventAsync(guildId, data, scheduledEvent, storedEvent, ct);
continue; continue;
} }
storedEvent.Status = scheduledEvent.Status; storedEvent.Status = scheduledEvent.Status;
var statusChangedResponseResult = storedEvent.Status switch { var statusChangedResponseResult = storedEvent.Status switch
{
GuildScheduledEventStatus.Scheduled => GuildScheduledEventStatus.Scheduled =>
await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct), await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct),
GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed => GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed =>
@ -172,16 +203,20 @@ public partial class GuildUpdateService : BackgroundService {
}; };
if (!statusChangedResponseResult.IsSuccess) if (!statusChangedResponseResult.IsSuccess)
{
_logger.LogWarning( _logger.LogWarning(
"Error handling scheduled event status update.\n{ErrorMessage}", "Error handling scheduled event status update.\n{ErrorMessage}",
statusChangedResponseResult.Error.Message); statusChangedResponseResult.Error.Message);
} }
} }
}
private async Task TickScheduledEventAsync( private async Task TickScheduledEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData, Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData,
CancellationToken ct) { CancellationToken ct)
if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) { {
if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime)
{
await TryAutoStartEventAsync(guildId, data, scheduledEvent, ct); await TryAutoStartEventAsync(guildId, data, scheduledEvent, ct);
return; return;
} }
@ -190,10 +225,14 @@ public partial class GuildUpdateService : BackgroundService {
|| eventData.EarlyNotificationSent || eventData.EarlyNotificationSent
|| DateTimeOffset.UtcNow || DateTimeOffset.UtcNow
< scheduledEvent.ScheduledStartTime < scheduledEvent.ScheduledStartTime
- GuildSettings.EventEarlyNotificationOffset.Get(data.Settings)) return; - GuildSettings.EventEarlyNotificationOffset.Get(data.Settings))
{
return;
}
var earlyResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct); var earlyResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct);
if (earlyResult.IsSuccess) { if (earlyResult.IsSuccess)
{
eventData.EarlyNotificationSent = true; eventData.EarlyNotificationSent = true;
return; return;
} }
@ -204,54 +243,82 @@ public partial class GuildUpdateService : BackgroundService {
} }
private async Task TryAutoStartEventAsync( private async Task TryAutoStartEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, CancellationToken ct) { Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, CancellationToken ct)
{
if (GuildSettings.AutoStartEvents.Get(data.Settings) if (GuildSettings.AutoStartEvents.Get(data.Settings)
&& scheduledEvent.Status is not GuildScheduledEventStatus.Active) { && scheduledEvent.Status is not GuildScheduledEventStatus.Active)
{
var startResult = await _eventApi.ModifyGuildScheduledEventAsync( var startResult = await _eventApi.ModifyGuildScheduledEventAsync(
guildId, scheduledEvent.ID, guildId, scheduledEvent.ID,
status: GuildScheduledEventStatus.Active, ct: ct); status: GuildScheduledEventStatus.Active, ct: ct);
if (!startResult.IsSuccess) if (!startResult.IsSuccess)
{
_logger.LogWarning( _logger.LogWarning(
"Error in automatic scheduled event start request.\n{ErrorMessage}", "Error in automatic scheduled event start request.\n{ErrorMessage}",
startResult.Error.Message); startResult.Error.Message);
} }
} }
}
private async Task TickMemberAsync( private async Task TickMemberAsync(
Snowflake guildId, IUser user, IGuildMember member, MemberData memberData, Snowflake defaultRole, Snowflake guildId, IUser user, IGuildMember member, MemberData memberData, Snowflake defaultRole,
JsonNode cfg, CancellationToken ct) { JsonNode cfg, CancellationToken ct)
{
if (defaultRole.Value is not 0 && !memberData.Roles.Contains(defaultRole.Value)) if (defaultRole.Value is not 0 && !memberData.Roles.Contains(defaultRole.Value))
{
_ = _guildApi.AddGuildMemberRoleAsync( _ = _guildApi.AddGuildMemberRoleAsync(
guildId, user.ID, defaultRole, ct: ct); guildId, user.ID, defaultRole, ct: ct);
}
if (DateTimeOffset.UtcNow > memberData.BannedUntil) { if (DateTimeOffset.UtcNow > memberData.BannedUntil)
{
var unbanResult = await _guildApi.RemoveGuildBanAsync( var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, user.ID, Messages.PunishmentExpired.EncodeHeader(), ct); guildId, user.ID, Messages.PunishmentExpired.EncodeHeader(), ct);
if (unbanResult.IsSuccess) if (!unbanResult.IsSuccess)
memberData.BannedUntil = null; {
else
_logger.LogWarning( _logger.LogWarning(
"Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message); "Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message);
return;
}
memberData.BannedUntil = null;
} }
for (var i = memberData.Reminders.Count - 1; i >= 0; i--) for (var i = memberData.Reminders.Count - 1; i >= 0; i--)
{
await TickReminderAsync(memberData.Reminders[i], user, memberData, ct); await TickReminderAsync(memberData.Reminders[i], user, memberData, ct);
if (GuildSettings.RenameHoistedUsers.Get(cfg)) await FilterNicknameAsync(guildId, user, member, ct);
} }
private Task FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member, CancellationToken ct) { if (GuildSettings.RenameHoistedUsers.Get(cfg))
{
await FilterNicknameAsync(guildId, user, member, ct);
}
}
private Task FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member, CancellationToken ct)
{
var currentNickname = member.Nickname.IsDefined(out var nickname) var currentNickname = member.Nickname.IsDefined(out var nickname)
? nickname ? nickname
: user.GlobalName ?? user.Username; : user.GlobalName ?? user.Username;
var characterList = currentNickname.ToList(); var characterList = currentNickname.ToList();
var usernameChanged = false; var usernameChanged = false;
foreach (var character in currentNickname) foreach (var character in currentNickname)
if (IllegalCharsRegex().IsMatch(character.ToString())) { {
if (IllegalChars().IsMatch(character.ToString()))
{
characterList.Remove(character); characterList.Remove(character);
usernameChanged = true; usernameChanged = true;
} else { break; } continue;
}
break;
}
if (!usernameChanged)
{
return Task.CompletedTask;
}
if (!usernameChanged) return Task.CompletedTask;
var newNickname = string.Concat(characterList.ToArray()); var newNickname = string.Concat(characterList.ToArray());
_ = _guildApi.ModifyGuildMemberAsync( _ = _guildApi.ModifyGuildMemberAsync(
@ -264,10 +331,14 @@ public partial class GuildUpdateService : BackgroundService {
} }
[GeneratedRegex("[^0-9A-zЁА-яё]")] [GeneratedRegex("[^0-9A-zЁА-яё]")]
private static partial Regex IllegalCharsRegex(); private static partial Regex IllegalChars();
private async Task TickReminderAsync(Reminder reminder, IUser user, MemberData memberData, CancellationToken ct) { private async Task TickReminderAsync(Reminder reminder, IUser user, MemberData memberData, CancellationToken ct)
if (DateTimeOffset.UtcNow < reminder.At) return; {
if (DateTimeOffset.UtcNow < reminder.At)
{
return;
}
var embed = new EmbedBuilder().WithSmallTitle( var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.Reminder, user.GetTag()), user) string.Format(Messages.Reminder, user.GetTag()), user)
@ -276,13 +347,18 @@ public partial class GuildUpdateService : BackgroundService {
.WithColour(ColorsList.Magenta) .WithColour(ColorsList.Magenta)
.Build(); .Build();
if (!embed.IsDefined(out var built)) return; if (!embed.IsDefined(out var built))
{
return;
}
var messageResult = await _channelApi.CreateMessageAsync( var messageResult = await _channelApi.CreateMessageAsync(
reminder.Channel.ToSnowflake(), Mention.User(user), embeds: new[] { built }, ct: ct); reminder.Channel.ToSnowflake(), Mention.User(user), embeds: new[] { built }, ct: ct);
if (!messageResult.IsSuccess) if (!messageResult.IsSuccess)
{
_logger.LogWarning( _logger.LogWarning(
"Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message); "Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message);
}
memberData.Reminders.Remove(reminder); memberData.Reminders.Remove(reminder);
} }
@ -298,15 +374,19 @@ public partial class GuildUpdateService : BackgroundService {
/// <param name="ct">The cancellation token for this operation.</param> /// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A notification sending result which may or may not have succeeded.</returns> /// <returns>A notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventCreatedMessage( private async Task<Result> SendScheduledEventCreatedMessage(
IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) { IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default)
{
if (!scheduledEvent.Creator.IsDefined(out var creator)) if (!scheduledEvent.Creator.IsDefined(out var creator))
{
return new ArgumentNullError(nameof(scheduledEvent.Creator)); return new ArgumentNullError(nameof(scheduledEvent.Creator));
}
Result<string> embedDescriptionResult; Result<string> embedDescriptionResult;
var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null } var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null }
? scheduledEvent.Description.Value ? scheduledEvent.Description.Value
: string.Empty; : string.Empty;
embedDescriptionResult = scheduledEvent.EntityType switch { embedDescriptionResult = scheduledEvent.EntityType switch
{
GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice =>
GetLocalEventCreatedEmbedDescription(scheduledEvent, eventDescription), GetLocalEventCreatedEmbedDescription(scheduledEvent, eventDescription),
GuildScheduledEventEntityType.External => GetExternalScheduledEventCreatedEmbedDescription( GuildScheduledEventEntityType.External => GetExternalScheduledEventCreatedEmbedDescription(
@ -315,7 +395,9 @@ public partial class GuildUpdateService : BackgroundService {
}; };
if (!embedDescriptionResult.IsDefined(out var embedDescription)) if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return Result.FromError(embedDescriptionResult); return Result.FromError(embedDescriptionResult);
}
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator) .WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
@ -325,7 +407,10 @@ public partial class GuildUpdateService : BackgroundService {
.WithCurrentTimestamp() .WithCurrentTimestamp()
.WithColour(ColorsList.White) .WithColour(ColorsList.White)
.Build(); .Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed); if (!embed.IsDefined(out var built))
{
return Result.FromError(embed);
}
var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty() var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty()
? Mention.Role(GuildSettings.EventNotificationRole.Get(settings)) ? Mention.Role(GuildSettings.EventNotificationRole.Get(settings))
@ -345,14 +430,23 @@ public partial class GuildUpdateService : BackgroundService {
} }
private static Result<string> GetExternalScheduledEventCreatedEmbedDescription( private static Result<string> GetExternalScheduledEventCreatedEmbedDescription(
IGuildScheduledEvent scheduledEvent, string eventDescription) { IGuildScheduledEvent scheduledEvent, string eventDescription)
{
Result<string> embedDescription; Result<string> embedDescription;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)); return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));
}
if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
{
return new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); return new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime));
}
if (!metadata.Location.IsDefined(out var location)) if (!metadata.Location.IsDefined(out var location))
{
return new ArgumentNullError(nameof(metadata.Location)); return new ArgumentNullError(nameof(metadata.Location));
}
embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote( embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format( string.Format(
@ -365,9 +459,12 @@ public partial class GuildUpdateService : BackgroundService {
} }
private static Result<string> GetLocalEventCreatedEmbedDescription( private static Result<string> GetLocalEventCreatedEmbedDescription(
IGuildScheduledEvent scheduledEvent, string eventDescription) { IGuildScheduledEvent scheduledEvent, string eventDescription)
{
if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
{
return new ArgumentNullError(nameof(scheduledEvent.ChannelID)); return new ArgumentNullError(nameof(scheduledEvent.ChannelID));
}
return $"{eventDescription}\n\n{Markdown.BlockQuote( return $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format( string.Format(
@ -378,7 +475,8 @@ public partial class GuildUpdateService : BackgroundService {
} }
/// <summary> /// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole"/> and event subscribers, /// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event
/// subscribers,
/// when a scheduled event has started or completed /// when a scheduled event has started or completed
/// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set. /// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary> /// </summary>
@ -387,11 +485,14 @@ public partial class GuildUpdateService : BackgroundService {
/// <param name="ct">The cancellation token for this operation</param> /// <param name="ct">The cancellation token for this operation</param>
/// <returns>A reminder/notification sending result which may or may not have succeeded.</returns> /// <returns>A reminder/notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventUpdatedMessage( private async Task<Result> SendScheduledEventUpdatedMessage(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default) { IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
if (scheduledEvent.Status == GuildScheduledEventStatus.Active) { {
if (scheduledEvent.Status == GuildScheduledEventStatus.Active)
{
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow; data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
var embedDescriptionResult = scheduledEvent.EntityType switch { var embedDescriptionResult = scheduledEvent.EntityType switch
{
GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice =>
GetLocalEventStartedEmbedDescription(scheduledEvent), GetLocalEventStartedEmbedDescription(scheduledEvent),
GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent), GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent),
@ -401,9 +502,14 @@ public partial class GuildUpdateService : BackgroundService {
var contentResult = await _utility.GetEventNotificationMentions( var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Settings, ct); scheduledEvent, data.Settings, ct);
if (!contentResult.IsDefined(out var content)) if (!contentResult.IsDefined(out var content))
{
return Result.FromError(contentResult); return Result.FromError(contentResult);
}
if (!embedDescriptionResult.IsDefined(out var embedDescription)) if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return Result.FromError(embedDescriptionResult); return Result.FromError(embedDescriptionResult);
}
var startedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name)) var startedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name))
.WithDescription(embedDescription) .WithDescription(embedDescription)
@ -411,7 +517,10 @@ public partial class GuildUpdateService : BackgroundService {
.WithCurrentTimestamp() .WithCurrentTimestamp()
.Build(); .Build();
if (!startedEmbed.IsDefined(out var startedBuilt)) return Result.FromError(startedEmbed); if (!startedEmbed.IsDefined(out var startedBuilt))
{
return Result.FromError(startedEmbed);
}
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings), GuildSettings.EventNotificationChannel.Get(data.Settings),
@ -419,7 +528,10 @@ public partial class GuildUpdateService : BackgroundService {
} }
if (scheduledEvent.Status != GuildScheduledEventStatus.Completed) if (scheduledEvent.Status != GuildScheduledEventStatus.Completed)
{
return new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)); return new ArgumentOutOfRangeError(nameof(scheduledEvent.Status));
}
data.ScheduledEvents.Remove(scheduledEvent.ID.Value); data.ScheduledEvents.Remove(scheduledEvent.ID.Value);
var completedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name)) var completedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name))
@ -434,17 +546,22 @@ public partial class GuildUpdateService : BackgroundService {
.Build(); .Build();
if (!completedEmbed.IsDefined(out var completedBuilt)) if (!completedEmbed.IsDefined(out var completedBuilt))
{
return Result.FromError(completedEmbed); return Result.FromError(completedEmbed);
}
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings), GuildSettings.EventNotificationChannel.Get(data.Settings),
embeds: new[] { completedBuilt }, ct: ct); embeds: new[] { completedBuilt }, ct: ct);
} }
private static Result<string> GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) { private static Result<string> GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)
{
Result<string> embedDescription; Result<string> embedDescription;
if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
{
return new ArgumentNullError(nameof(scheduledEvent.ChannelID)); return new ArgumentNullError(nameof(scheduledEvent.ChannelID));
}
embedDescription = string.Format( embedDescription = string.Format(
Messages.DescriptionLocalEventStarted, Messages.DescriptionLocalEventStarted,
@ -453,14 +570,23 @@ public partial class GuildUpdateService : BackgroundService {
return embedDescription; return embedDescription;
} }
private static Result<string> GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) { private static Result<string> GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)
{
Result<string> embedDescription; Result<string> embedDescription;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)); return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));
}
if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
{
return new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); return new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime));
}
if (!metadata.Location.IsDefined(out var location)) if (!metadata.Location.IsDefined(out var location))
{
return new ArgumentNullError(nameof(metadata.Location)); return new ArgumentNullError(nameof(metadata.Location));
}
embedDescription = string.Format( embedDescription = string.Format(
Messages.DescriptionExternalEventStarted, Messages.DescriptionExternalEventStarted,
@ -471,14 +597,20 @@ public partial class GuildUpdateService : BackgroundService {
} }
private async Task<Result> SendEarlyEventNotificationAsync( private async Task<Result> SendEarlyEventNotificationAsync(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct) { IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct)
{
var currentUserResult = await _userApi.GetCurrentUserAsync(ct); var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); if (!currentUserResult.IsDefined(out var currentUser))
{
return Result.FromError(currentUserResult);
}
var contentResult = await _utility.GetEventNotificationMentions( var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Settings, ct); scheduledEvent, data.Settings, ct);
if (!contentResult.IsDefined(out var content)) if (!contentResult.IsDefined(out var content))
{
return Result.FromError(contentResult); return Result.FromError(contentResult);
}
var earlyResult = new EmbedBuilder() var earlyResult = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser) .WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser)
@ -486,7 +618,10 @@ public partial class GuildUpdateService : BackgroundService {
.WithCurrentTimestamp() .WithCurrentTimestamp()
.Build(); .Build();
if (!earlyResult.IsDefined(out var earlyBuilt)) return Result.FromError(earlyResult); if (!earlyResult.IsDefined(out var earlyBuilt))
{
return Result.FromError(earlyResult);
}
return (Result)await _channelApi.CreateMessageAsync( return (Result)await _channelApi.CreateMessageAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings), GuildSettings.EventNotificationChannel.Get(data.Settings),

View file

@ -16,7 +16,8 @@ namespace Boyfriend.Services;
/// Provides utility methods that cannot be transformed to extension methods because they require usage /// Provides utility methods that cannot be transformed to extension methods because they require usage
/// of some Discord APIs. /// of some Discord APIs.
/// </summary> /// </summary>
public class UtilityService : IHostedService { public sealed class UtilityService : IHostedService
{
private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi; private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi; private readonly IDiscordRestGuildAPI _guildApi;
@ -24,18 +25,21 @@ public class UtilityService : IHostedService {
public UtilityService( public UtilityService(
IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi,
IDiscordRestUserAPI userApi) { IDiscordRestUserAPI userApi)
{
_channelApi = channelApi; _channelApi = channelApi;
_eventApi = eventApi; _eventApi = eventApi;
_guildApi = guildApi; _guildApi = guildApi;
_userApi = userApi; _userApi = userApi;
} }
public Task StartAsync(CancellationToken ct) { public Task StartAsync(CancellationToken ct)
{
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task StopAsync(CancellationToken ct) { public Task StopAsync(CancellationToken ct)
{
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -58,29 +62,42 @@ public class UtilityService : IHostedService {
/// </list> /// </list>
/// </returns> /// </returns>
public async Task<Result<string?>> CheckInteractionsAsync( public async Task<Result<string?>> CheckInteractionsAsync(
Snowflake guildId, Snowflake interacterId, Snowflake targetId, string action, CancellationToken ct = default) { Snowflake guildId, Snowflake interacterId, Snowflake targetId, string action, CancellationToken ct = default)
{
if (interacterId == targetId) if (interacterId == targetId)
{
return Result<string?>.FromSuccess($"UserCannot{action}Themselves".Localized()); return Result<string?>.FromSuccess($"UserCannot{action}Themselves".Localized());
}
var currentUserResult = await _userApi.GetCurrentUserAsync(ct); var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) if (!currentUserResult.IsDefined(out var currentUser))
{
return Result<string?>.FromError(currentUserResult); return Result<string?>.FromError(currentUserResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct); var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct);
if (!guildResult.IsDefined(out var guild)) if (!guildResult.IsDefined(out var guild))
{
return Result<string?>.FromError(guildResult); return Result<string?>.FromError(guildResult);
}
var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct); var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct);
if (!targetMemberResult.IsDefined(out var targetMember)) if (!targetMemberResult.IsDefined(out var targetMember))
{
return Result<string?>.FromSuccess(null); return Result<string?>.FromSuccess(null);
}
var currentMemberResult = await _guildApi.GetGuildMemberAsync(guildId, currentUser.ID, ct); var currentMemberResult = await _guildApi.GetGuildMemberAsync(guildId, currentUser.ID, ct);
if (!currentMemberResult.IsDefined(out var currentMember)) if (!currentMemberResult.IsDefined(out var currentMember))
{
return Result<string?>.FromError(currentMemberResult); return Result<string?>.FromError(currentMemberResult);
}
var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct); var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct);
if (!rolesResult.IsDefined(out var roles)) if (!rolesResult.IsDefined(out var roles))
{
return Result<string?>.FromError(rolesResult); return Result<string?>.FromError(rolesResult);
}
var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId, ct); var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId, ct);
return interacterResult.IsDefined(out var interacter) return interacterResult.IsDefined(out var interacter)
@ -90,26 +107,41 @@ public class UtilityService : IHostedService {
private static Result<string?> CheckInteractions( private static Result<string?> CheckInteractions(
string action, IGuild guild, IReadOnlyList<IRole> roles, IGuildMember targetMember, IGuildMember currentMember, string action, IGuild guild, IReadOnlyList<IRole> roles, IGuildMember targetMember, IGuildMember currentMember,
IGuildMember interacter) { IGuildMember interacter)
{
if (!targetMember.User.IsDefined(out var targetUser)) if (!targetMember.User.IsDefined(out var targetUser))
{
return new ArgumentNullError(nameof(targetMember.User)); return new ArgumentNullError(nameof(targetMember.User));
}
if (!interacter.User.IsDefined(out var interacterUser)) if (!interacter.User.IsDefined(out var interacterUser))
{
return new ArgumentNullError(nameof(interacter.User)); return new ArgumentNullError(nameof(interacter.User));
}
if (currentMember.User == targetMember.User) if (currentMember.User == targetMember.User)
{
return Result<string?>.FromSuccess($"UserCannot{action}Bot".Localized()); return Result<string?>.FromSuccess($"UserCannot{action}Bot".Localized());
}
if (targetUser.ID == guild.OwnerID) return Result<string?>.FromSuccess($"UserCannot{action}Owner".Localized()); if (targetUser.ID == guild.OwnerID)
{
return Result<string?>.FromSuccess($"UserCannot{action}Owner".Localized());
}
var targetRoles = roles.Where(r => targetMember.Roles.Contains(r.ID)).ToList(); var targetRoles = roles.Where(r => targetMember.Roles.Contains(r.ID)).ToList();
var botRoles = roles.Where(r => currentMember.Roles.Contains(r.ID)); var botRoles = roles.Where(r => currentMember.Roles.Contains(r.ID));
var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position); var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position);
if (targetBotRoleDiff >= 0) if (targetBotRoleDiff >= 0)
{
return Result<string?>.FromSuccess($"BotCannot{action}Target".Localized()); return Result<string?>.FromSuccess($"BotCannot{action}Target".Localized());
}
if (interacterUser.ID == guild.OwnerID) if (interacterUser.ID == guild.OwnerID)
{
return Result<string?>.FromSuccess(null); return Result<string?>.FromSuccess(null);
}
var interacterRoles = roles.Where(r => interacter.Roles.Contains(r.ID)); var interacterRoles = roles.Where(r => interacter.Roles.Contains(r.ID));
var targetInteracterRoleDiff var targetInteracterRoleDiff
@ -120,7 +152,8 @@ public class UtilityService : IHostedService {
} }
/// <summary> /// <summary>
/// Gets the string mentioning the <see cref="GuildSettings.EventNotificationRole"/> and event subscribers related to a scheduled /// Gets the string mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event subscribers related to
/// a scheduled
/// event. /// event.
/// </summary> /// </summary>
/// <param name="scheduledEvent"> /// <param name="scheduledEvent">
@ -130,21 +163,24 @@ public class UtilityService : IHostedService {
/// <param name="ct">The cancellation token for this operation.</param> /// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A result containing the string which may or may not have succeeded.</returns> /// <returns>A result containing the string which may or may not have succeeded.</returns>
public async Task<Result<string>> GetEventNotificationMentions( public async Task<Result<string>> GetEventNotificationMentions(
IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) { IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default)
{
var builder = new StringBuilder(); var builder = new StringBuilder();
var role = GuildSettings.EventNotificationRole.Get(settings); var role = GuildSettings.EventNotificationRole.Get(settings);
var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync( var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync(
scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct); scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct);
if (!usersResult.IsDefined(out var users)) return Result<string>.FromError(usersResult); if (!usersResult.IsDefined(out var users))
{
return Result<string>.FromError(usersResult);
}
if (role.Value is not 0) if (role.Value is not 0)
{
builder.Append($"{Mention.Role(role)} "); builder.Append($"{Mention.Role(role)} ");
}
builder = users.Where( builder = users.Where(
user => { user => user.GuildMember.IsDefined(out var member) && !member.Roles.Contains(role))
if (!user.GuildMember.IsDefined(out var member)) return true;
return !member.Roles.Contains(role);
})
.Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} ")); .Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} "));
return builder.ToString(); return builder.ToString();
} }
@ -160,17 +196,22 @@ public class UtilityService : IHostedService {
/// <param name="description">The description of the embed.</param> /// <param name="description">The description of the embed.</param>
/// <param name="avatar">The user whose avatar will be displayed next to the <paramref name="title" /> of the embed.</param> /// <param name="avatar">The user whose avatar will be displayed next to the <paramref name="title" /> of the embed.</param>
/// <param name="color">The color of the embed.</param> /// <param name="color">The color of the embed.</param>
/// <param name="isPublic">Whether or not the embed should be sent in <see cref="GuildSettings.PublicFeedbackChannel"/></param> /// <param name="isPublic">
/// Whether or not the embed should be sent in <see cref="GuildSettings.PublicFeedbackChannel" />
/// </param>
/// <param name="ct">The cancellation token for this operation.</param> /// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A result which has succeeded.</returns> /// <returns>A result which has succeeded.</returns>
public Result LogActionAsync( public Result LogActionAsync(
JsonNode cfg, Snowflake channelId, IUser user, string title, string description, IUser avatar, JsonNode cfg, Snowflake channelId, IUser user, string title, string description, IUser avatar,
Color color, bool isPublic = true, CancellationToken ct = default) { Color color, bool isPublic = true, CancellationToken ct = default)
{
var publicChannel = GuildSettings.PublicFeedbackChannel.Get(cfg); var publicChannel = GuildSettings.PublicFeedbackChannel.Get(cfg);
var privateChannel = GuildSettings.PrivateFeedbackChannel.Get(cfg); var privateChannel = GuildSettings.PrivateFeedbackChannel.Get(cfg);
if (GuildSettings.PublicFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId) if (GuildSettings.PublicFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId)
&& GuildSettings.PrivateFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId)) && GuildSettings.PrivateFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId))
{
return Result.FromSuccess(); return Result.FromSuccess();
}
var logEmbed = new EmbedBuilder().WithSmallTitle(title, avatar) var logEmbed = new EmbedBuilder().WithSmallTitle(title, avatar)
.WithDescription(description) .WithDescription(description)
@ -180,20 +221,27 @@ public class UtilityService : IHostedService {
.Build(); .Build();
if (!logEmbed.IsDefined(out var logBuilt)) if (!logEmbed.IsDefined(out var logBuilt))
{
return Result.FromError(logEmbed); return Result.FromError(logEmbed);
}
var builtArray = new[] { logBuilt }; var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time // Not awaiting to reduce response time
if (isPublic && publicChannel != channelId) if (isPublic && publicChannel != channelId)
{
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
publicChannel, embeds: builtArray, publicChannel, embeds: builtArray,
ct: ct); ct: ct);
}
if (privateChannel != publicChannel if (privateChannel != publicChannel
&& privateChannel != channelId) && privateChannel != channelId)
{
_ = _channelApi.CreateMessageAsync( _ = _channelApi.CreateMessageAsync(
privateChannel, embeds: builtArray, privateChannel, embeds: builtArray,
ct: ct); ct: ct);
}
return Result.FromSuccess(); return Result.FromSuccess();
} }