From 941db426d9a8979e908a9360357104314a32b69d Mon Sep 17 00:00:00 2001 From: Octol1ttle Date: Thu, 12 Oct 2023 19:57:39 +0500 Subject: [PATCH] refactor: split extension methods into separate classes --- src/Commands/AboutCommandGroup.cs | 1 + src/Commands/BanCommandGroup.cs | 5 +- src/Commands/ClearCommandGroup.cs | 1 + .../Events/ErrorLoggingPostExecutionEvent.cs | 1 + .../Events/LoggingPreparationErrorEvent.cs | 1 + src/Commands/KickCommandGroup.cs | 3 +- src/Commands/MuteCommandGroup.cs | 5 +- src/Commands/PingCommandGroup.cs | 1 + src/Commands/RemindCommandGroup.cs | 1 + src/Commands/SettingsCommandGroup.cs | 1 + src/Commands/ToolsCommandGroup.cs | 1 + src/Data/Options/SnowflakeOption.cs | 1 + src/Extensions.cs | 366 ------------------ src/Extensions/CollectionExtensions.cs | 40 ++ src/Extensions/CommandContextExtensions.cs | 19 + src/Extensions/DiffPaneModelExtensions.cs | 31 ++ src/Extensions/EmbedBuilderExtensions.cs | 149 +++++++ src/Extensions/FeedbackServiceExtensions.cs | 19 + .../GuildScheduledEventExtensions.cs | 28 ++ src/Extensions/LoggerExtensions.cs | 35 ++ src/Extensions/SnowflakeExtensions.cs | 32 ++ src/Extensions/StringExtensions.cs | 52 +++ src/Extensions/UInt64Extensions.cs | 12 + src/Extensions/UserExtensions.cs | 11 + src/Responders/GuildLoadedResponder.cs | 1 + src/Responders/GuildMemberJoinedResponder.cs | 1 + src/Responders/MessageDeletedResponder.cs | 1 + src/Responders/MessageEditedResponder.cs | 1 + src/Services/Update/MemberUpdateService.cs | 1 + .../Update/ScheduledEventUpdateService.cs | 1 + src/Services/UtilityService.cs | 1 + 31 files changed, 452 insertions(+), 371 deletions(-) delete mode 100644 src/Extensions.cs create mode 100644 src/Extensions/CollectionExtensions.cs create mode 100644 src/Extensions/CommandContextExtensions.cs create mode 100644 src/Extensions/DiffPaneModelExtensions.cs create mode 100644 src/Extensions/EmbedBuilderExtensions.cs create mode 100644 src/Extensions/FeedbackServiceExtensions.cs create mode 100644 src/Extensions/GuildScheduledEventExtensions.cs create mode 100644 src/Extensions/LoggerExtensions.cs create mode 100644 src/Extensions/SnowflakeExtensions.cs create mode 100644 src/Extensions/StringExtensions.cs create mode 100644 src/Extensions/UInt64Extensions.cs create mode 100644 src/Extensions/UserExtensions.cs diff --git a/src/Commands/AboutCommandGroup.cs b/src/Commands/AboutCommandGroup.cs index c0349078..8529d487 100644 --- a/src/Commands/AboutCommandGroup.cs +++ b/src/Commands/AboutCommandGroup.cs @@ -2,6 +2,7 @@ using System.Text; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Commands.Attributes; using Remora.Commands.Groups; diff --git a/src/Commands/BanCommandGroup.cs b/src/Commands/BanCommandGroup.cs index 010a9dad..8b62858a 100644 --- a/src/Commands/BanCommandGroup.cs +++ b/src/Commands/BanCommandGroup.cs @@ -2,6 +2,7 @@ using System.Text; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Octobot.Services.Update; using Remora.Commands.Attributes; @@ -53,7 +54,7 @@ public BanCommandGroup( /// The user to ban. /// The duration for this ban. The user will be automatically unbanned after this duration. /// - /// The reason for this ban. Must be encoded with when passed to + /// The reason for this ban. Must be encoded with when passed to /// . /// /// @@ -196,7 +197,7 @@ var interactionResult /// /// The user to unban. /// - /// The reason for this unban. Must be encoded with when passed to + /// The reason for this unban. Must be encoded with when passed to /// . /// /// diff --git a/src/Commands/ClearCommandGroup.cs b/src/Commands/ClearCommandGroup.cs index c963fdf9..a6ac188c 100644 --- a/src/Commands/ClearCommandGroup.cs +++ b/src/Commands/ClearCommandGroup.cs @@ -2,6 +2,7 @@ using System.Text; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Commands.Attributes; using Remora.Commands.Groups; diff --git a/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs b/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs index 90002678..d6a66ccc 100644 --- a/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs +++ b/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; +using Octobot.Extensions; using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Services; using Remora.Results; diff --git a/src/Commands/Events/LoggingPreparationErrorEvent.cs b/src/Commands/Events/LoggingPreparationErrorEvent.cs index b3f94252..be48e74b 100644 --- a/src/Commands/Events/LoggingPreparationErrorEvent.cs +++ b/src/Commands/Events/LoggingPreparationErrorEvent.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; +using Octobot.Extensions; using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Services; using Remora.Results; diff --git a/src/Commands/KickCommandGroup.cs b/src/Commands/KickCommandGroup.cs index 2ee99a94..05552a2d 100644 --- a/src/Commands/KickCommandGroup.cs +++ b/src/Commands/KickCommandGroup.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Commands.Attributes; using Remora.Commands.Groups; @@ -49,7 +50,7 @@ public KickCommandGroup( /// /// The member to kick. /// - /// The reason for this kick. Must be encoded with when passed to + /// The reason for this kick. Must be encoded with when passed to /// . /// /// diff --git a/src/Commands/MuteCommandGroup.cs b/src/Commands/MuteCommandGroup.cs index 4ec4c6c9..50fe7a3f 100644 --- a/src/Commands/MuteCommandGroup.cs +++ b/src/Commands/MuteCommandGroup.cs @@ -2,6 +2,7 @@ using System.Text; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Octobot.Services.Update; using Remora.Commands.Attributes; @@ -50,7 +51,7 @@ public MuteCommandGroup( /// The member to mute. /// The duration for this mute. The member will be automatically unmuted after this duration. /// - /// The reason for this mute. Must be encoded with when passed to + /// The reason for this mute. Must be encoded with when passed to /// . /// /// @@ -213,7 +214,7 @@ private async Task TimeoutUserAsync( /// /// The member to unmute. /// - /// The reason for this unmute. Must be encoded with when passed to + /// The reason for this unmute. Must be encoded with when passed to /// . /// /// diff --git a/src/Commands/PingCommandGroup.cs b/src/Commands/PingCommandGroup.cs index a1b14bda..293fbffd 100644 --- a/src/Commands/PingCommandGroup.cs +++ b/src/Commands/PingCommandGroup.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Commands.Attributes; using Remora.Commands.Groups; diff --git a/src/Commands/RemindCommandGroup.cs b/src/Commands/RemindCommandGroup.cs index 966b3ec9..4a4f6a1f 100644 --- a/src/Commands/RemindCommandGroup.cs +++ b/src/Commands/RemindCommandGroup.cs @@ -2,6 +2,7 @@ using System.Text; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Commands.Attributes; using Remora.Commands.Groups; diff --git a/src/Commands/SettingsCommandGroup.cs b/src/Commands/SettingsCommandGroup.cs index fc4fbe74..317b5c86 100644 --- a/src/Commands/SettingsCommandGroup.cs +++ b/src/Commands/SettingsCommandGroup.cs @@ -4,6 +4,7 @@ using JetBrains.Annotations; using Octobot.Data; using Octobot.Data.Options; +using Octobot.Extensions; using Octobot.Services; using Remora.Commands.Attributes; using Remora.Commands.Groups; diff --git a/src/Commands/ToolsCommandGroup.cs b/src/Commands/ToolsCommandGroup.cs index 92f17857..6c70c01e 100644 --- a/src/Commands/ToolsCommandGroup.cs +++ b/src/Commands/ToolsCommandGroup.cs @@ -3,6 +3,7 @@ using System.Text; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Commands.Attributes; using Remora.Commands.Groups; diff --git a/src/Data/Options/SnowflakeOption.cs b/src/Data/Options/SnowflakeOption.cs index 2150725b..66ada96e 100644 --- a/src/Data/Options/SnowflakeOption.cs +++ b/src/Data/Options/SnowflakeOption.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using System.Text.RegularExpressions; +using Octobot.Extensions; using Remora.Discord.Extensions.Formatting; using Remora.Rest.Core; using Remora.Results; diff --git a/src/Extensions.cs b/src/Extensions.cs deleted file mode 100644 index 00d3d364..00000000 --- a/src/Extensions.cs +++ /dev/null @@ -1,366 +0,0 @@ -using System.Net; -using System.Text; -using DiffPlex.DiffBuilder.Model; -using Microsoft.Extensions.Logging; -using Remora.Discord.API; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Objects; -using Remora.Discord.Commands.Contexts; -using Remora.Discord.Commands.Extensions; -using Remora.Discord.Commands.Feedback.Services; -using Remora.Discord.Extensions.Embeds; -using Remora.Discord.Extensions.Formatting; -using Remora.Rest.Core; -using Remora.Results; - -namespace Octobot; - -public static class Extensions -{ - /// - /// Adds a footer representing that an action was performed by a . - /// - /// The builder to add the footer to. - /// The user that performed the action whose tag and avatar to use. - /// The builder with the added footer. - public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user) - { - var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256); - var avatarUrl = avatarUrlResult.IsSuccess - ? avatarUrlResult.Entity.AbsoluteUri - : CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri; - - return builder.WithFooter( - new EmbedFooter($"{Messages.IssuedBy}:\n{user.GetTag()}", avatarUrl)); - } - - /// - /// Adds a title using the author field, making it smaller than using the title field. - /// - /// The builder to add the small title to. - /// The text of the small title. - /// The user whose avatar to use in the small title. - /// The builder with the added small title in the author field. - public static EmbedBuilder WithSmallTitle( - this EmbedBuilder builder, string text, IUser? avatarSource = null) - { - Uri? avatarUrl = null; - if (avatarSource is not null) - { - var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256); - - avatarUrl = avatarUrlResult.IsSuccess - ? avatarUrlResult.Entity - : CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity; - } - - builder.Author = new EmbedAuthorBuilder(text, iconUrl: avatarUrl?.AbsoluteUri); - return builder; - } - - /// - /// Adds a user avatar in the thumbnail field. - /// - /// The builder to add the thumbnail to. - /// The user whose avatar to use in the thumbnail field. - /// The builder with the added avatar in the thumbnail field. - public static EmbedBuilder WithLargeUserAvatar( - this EmbedBuilder builder, IUser avatarSource) - { - var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256); - var avatarUrl = avatarUrlResult.IsSuccess - ? avatarUrlResult.Entity - : CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity; - - return builder.WithThumbnailUrl(avatarUrl.AbsoluteUri); - } - - /// - /// Adds a guild icon in the thumbnail field. - /// - /// The builder to add the thumbnail to. - /// The guild whose icon to use in the thumbnail field. - /// The builder with the added icon in the thumbnail field. - public static EmbedBuilder WithLargeGuildIcon( - this EmbedBuilder builder, IGuild iconSource) - { - var iconUrlResult = CDN.GetGuildIconUrl(iconSource, imageSize: 256); - return iconUrlResult.IsSuccess - ? builder.WithThumbnailUrl(iconUrlResult.Entity.AbsoluteUri) - : builder; - } - - /// - /// Adds a guild banner in the image field. - /// - /// The builder to add the image to. - /// The guild whose banner to use in the image field. - /// The builder with the added banner in the image field. - public static EmbedBuilder WithGuildBanner( - this EmbedBuilder builder, IGuild bannerSource) - { - return bannerSource.Banner is not null - ? builder.WithImageUrl(CDN.GetGuildBannerUrl(bannerSource).Entity.AbsoluteUri) - : builder; - } - - /// - /// Adds a footer representing that the action was performed in the . - /// - /// The builder to add the footer to. - /// The guild whose name and icon to use. - /// The builder with the added footer. - public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild) - { - var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); - var iconUrl = iconUrlResult.IsSuccess - ? iconUrlResult.Entity.AbsoluteUri - : default(Optional); - - return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl)); - } - - /// - /// Adds a title representing that the action happened in the . - /// - /// The builder to add the title to. - /// The guild whose name and icon to use. - /// The builder with the added title. - public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild) - { - var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); - var iconUrl = iconUrlResult.IsSuccess - ? iconUrlResult.Entity.AbsoluteUri - : null; - - builder.Author = new EmbedAuthorBuilder(guild.Name, iconUrl: iconUrl); - return builder; - } - - /// - /// Adds a scheduled event's cover image. - /// - /// The builder to add the image to. - /// The ID of the scheduled event whose image to use. - /// The Optional containing the image hash. - /// The builder with the added cover image. - public static EmbedBuilder WithEventCover( - this EmbedBuilder builder, Snowflake eventId, Optional imageHashOptional) - { - if (!imageHashOptional.IsDefined(out var imageHash)) - { - return builder; - } - - var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024); - return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder; - } - - /// - /// Sanitizes a string for use in by inserting zero-width spaces in between - /// symbols used to format the string with block code. - /// - /// The string to sanitize. - /// The sanitized string that can be safely used in . - private static string SanitizeForBlockCode(this string s) - { - return s.Replace("```", "​`​`​`​"); - } - - /// - /// Sanitizes a string (see ) and formats the string to use Markdown Block Code - /// formatting with a specified - /// language for syntax highlighting. - /// - /// The string to sanitize and format. - /// - /// - /// The sanitized string formatted to use Markdown Block Code with a specified - /// language for syntax highlighting. - /// - public static string InBlockCode(this string s, string language = "") - { - s = s.SanitizeForBlockCode(); - return - $"```{language}\n{s.SanitizeForBlockCode()}{(s.EndsWith("`", StringComparison.Ordinal) || string.IsNullOrWhiteSpace(s) ? " " : "")}```"; - } - - public static string Localized(this string key) - { - return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key; - } - - /// - /// Encodes a string to allow its transmission in request headers. - /// - /// Used when encountering "Request headers must contain only ASCII characters". - /// The string to encode. - /// An encoded string with spaces kept intact. - public static string EncodeHeader(this string s) - { - return WebUtility.UrlEncode(s).Replace('+', ' '); - } - - public static string AsMarkdown(this DiffPaneModel model) - { - var builder = new StringBuilder(); - foreach (var line in model.Lines) - { - if (line.Type is ChangeType.Deleted) - { - builder.Append("-- "); - } - - if (line.Type is ChangeType.Inserted) - { - builder.Append("++ "); - } - - if (line.Type is not ChangeType.Imaginary) - { - builder.AppendLine(line.Text); - } - } - - return InBlockCode(builder.ToString(), "diff"); - } - - public static string GetTag(this IUser user) - { - return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}"; - } - - public static Snowflake ToSnowflake(this ulong id) - { - return DiscordSnowflake.New(id); - } - - public static TResult? MaxOrDefault( - this IEnumerable source, Func selector) - { - var list = source.ToList(); - return list.Any() ? list.Max(selector) : default; - } - - public static bool TryGetContextIDs( - this ICommandContext context, out Snowflake guildId, - out Snowflake channelId, out Snowflake executorId) - { - channelId = default; - executorId = default; - return context.TryGetGuildID(out guildId) - && context.TryGetChannelID(out channelId) - && context.TryGetUserID(out executorId); - } - - /// - /// Checks whether this Snowflake has any value set. - /// - /// The Snowflake to check. - /// true if the Snowflake has no value set or it's set to 0, false otherwise. - public static bool Empty(this Snowflake snowflake) - { - return snowflake.Value is 0; - } - - /// - /// Checks whether this snowflake is empty (see ) or it's equal to - /// - /// - /// The Snowflake to check for emptiness - /// The Snowflake to check for equality with . - /// - /// true if is empty or is equal to , false - /// otherwise. - /// - /// - public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake) - { - return snowflake.Empty() || snowflake == anotherSnowflake; - } - - public static async Task SendContextualEmbedResultAsync( - this FeedbackService feedback, Result embedResult, CancellationToken ct = default) - { - if (!embedResult.IsDefined(out var embed)) - { - return Result.FromError(embedResult); - } - - return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct); - } - - /// - /// Checks if the has failed due to an error that has resulted from neither invalid user - /// input nor the execution environment and logs the error using the provided . - /// - /// - /// This has special behavior for - its exception will be passed to the - /// - /// - /// The logger to use. - /// The Result whose error check. - /// The message to use if this result has failed. - public static void LogResult(this ILogger logger, IResult result, string? message = "") - { - if (result.IsSuccess || result.Error.IsUserOrEnvironmentError()) - { - return; - } - - if (result.Error is ExceptionError exe) - { - logger.LogError(exe.Exception, "{ErrorMessage}", message); - return; - } - - logger.LogWarning("{UserMessage}\n{ResultErrorMessage}", message, result.Error.Message); - } - - public static void AddIfFailed(this List list, Result result) - { - if (!result.IsSuccess) - { - list.Add(result); - } - } - - /// - /// Return an appropriate result for a list of failed results. The list must only contain failed results. - /// - /// The list of failed results. - /// - /// A successful result if the list is empty, the only Result in the list, or - /// containing all results from the list. - /// - /// - public static Result AggregateErrors(this List list) - { - return list.Count switch - { - 0 => Result.FromSuccess(), - 1 => list[0], - _ => new AggregateError(list.Cast().ToArray()) - }; - } - - public static Result TryGetExternalEventData(this IGuildScheduledEvent scheduledEvent, out DateTimeOffset endTime, - out string? location) - { - endTime = default; - location = default; - if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) - { - return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)); - } - - if (!metadata.Location.IsDefined(out location)) - { - return new ArgumentNullError(nameof(metadata.Location)); - } - - return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime) - ? Result.FromSuccess() - : new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); - } -} diff --git a/src/Extensions/CollectionExtensions.cs b/src/Extensions/CollectionExtensions.cs new file mode 100644 index 00000000..5322d092 --- /dev/null +++ b/src/Extensions/CollectionExtensions.cs @@ -0,0 +1,40 @@ +using Remora.Results; + +namespace Octobot.Extensions; + +public static class CollectionExtensions +{ + public static TResult? MaxOrDefault( + this IEnumerable source, Func selector) + { + var list = source.ToList(); + return list.Any() ? list.Max(selector) : default; + } + + public static void AddIfFailed(this List list, Result result) + { + if (!result.IsSuccess) + { + list.Add(result); + } + } + + /// + /// Return an appropriate result for a list of failed results. The list must only contain failed results. + /// + /// The list of failed results. + /// + /// A successful result if the list is empty, the only Result in the list, or + /// containing all results from the list. + /// + /// + public static Result AggregateErrors(this List list) + { + return list.Count switch + { + 0 => Result.FromSuccess(), + 1 => list[0], + _ => new AggregateError(list.Cast().ToArray()) + }; + } +} diff --git a/src/Extensions/CommandContextExtensions.cs b/src/Extensions/CommandContextExtensions.cs new file mode 100644 index 00000000..a0c02f28 --- /dev/null +++ b/src/Extensions/CommandContextExtensions.cs @@ -0,0 +1,19 @@ +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Extensions; +using Remora.Rest.Core; + +namespace Octobot.Extensions; + +public static class CommandContextExtensions +{ + public static bool TryGetContextIDs( + this ICommandContext context, out Snowflake guildId, + out Snowflake channelId, out Snowflake executorId) + { + channelId = default; + executorId = default; + return context.TryGetGuildID(out guildId) + && context.TryGetChannelID(out channelId) + && context.TryGetUserID(out executorId); + } +} diff --git a/src/Extensions/DiffPaneModelExtensions.cs b/src/Extensions/DiffPaneModelExtensions.cs new file mode 100644 index 00000000..ec7b8eed --- /dev/null +++ b/src/Extensions/DiffPaneModelExtensions.cs @@ -0,0 +1,31 @@ +using System.Text; +using DiffPlex.DiffBuilder.Model; + +namespace Octobot.Extensions; + +public static class DiffPaneModelExtensions +{ + public static string AsMarkdown(this DiffPaneModel model) + { + var builder = new StringBuilder(); + foreach (var line in model.Lines) + { + if (line.Type is ChangeType.Deleted) + { + builder.Append("- "); + } + + if (line.Type is ChangeType.Inserted) + { + builder.Append("+ "); + } + + if (line.Type is not ChangeType.Imaginary) + { + builder.AppendLine(line.Text); + } + } + + return builder.ToString().InBlockCode("diff"); + } +} diff --git a/src/Extensions/EmbedBuilderExtensions.cs b/src/Extensions/EmbedBuilderExtensions.cs new file mode 100644 index 00000000..2d614035 --- /dev/null +++ b/src/Extensions/EmbedBuilderExtensions.cs @@ -0,0 +1,149 @@ +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Discord.Extensions.Embeds; +using Remora.Rest.Core; + +namespace Octobot.Extensions; + +public static class EmbedBuilderExtensions +{ + /// + /// Adds a footer representing that an action was performed by a . + /// + /// The builder to add the footer to. + /// The user that performed the action whose tag and avatar to use. + /// The builder with the added footer. + public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user) + { + var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256); + var avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity.AbsoluteUri + : CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri; + + return builder.WithFooter( + new EmbedFooter($"{Messages.IssuedBy}:\n{user.GetTag()}", avatarUrl)); + } + + /// + /// Adds a title using the author field, making it smaller than using the title field. + /// + /// The builder to add the small title to. + /// The text of the small title. + /// The user whose avatar to use in the small title. + /// The builder with the added small title in the author field. + public static EmbedBuilder WithSmallTitle( + this EmbedBuilder builder, string text, IUser? avatarSource = null) + { + Uri? avatarUrl = null; + if (avatarSource is not null) + { + var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256); + + avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity + : CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity; + } + + builder.Author = new EmbedAuthorBuilder(text, iconUrl: avatarUrl?.AbsoluteUri); + return builder; + } + + /// + /// Adds a user avatar in the thumbnail field. + /// + /// The builder to add the thumbnail to. + /// The user whose avatar to use in the thumbnail field. + /// The builder with the added avatar in the thumbnail field. + public static EmbedBuilder WithLargeUserAvatar( + this EmbedBuilder builder, IUser avatarSource) + { + var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256); + var avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity + : CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity; + + return builder.WithThumbnailUrl(avatarUrl.AbsoluteUri); + } + + /// + /// Adds a guild icon in the thumbnail field. + /// + /// The builder to add the thumbnail to. + /// The guild whose icon to use in the thumbnail field. + /// The builder with the added icon in the thumbnail field. + public static EmbedBuilder WithLargeGuildIcon( + this EmbedBuilder builder, IGuild iconSource) + { + var iconUrlResult = CDN.GetGuildIconUrl(iconSource, imageSize: 256); + return iconUrlResult.IsSuccess + ? builder.WithThumbnailUrl(iconUrlResult.Entity.AbsoluteUri) + : builder; + } + + /// + /// Adds a guild banner in the image field. + /// + /// The builder to add the image to. + /// The guild whose banner to use in the image field. + /// The builder with the added banner in the image field. + public static EmbedBuilder WithGuildBanner( + this EmbedBuilder builder, IGuild bannerSource) + { + return bannerSource.Banner is not null + ? builder.WithImageUrl(CDN.GetGuildBannerUrl(bannerSource).Entity.AbsoluteUri) + : builder; + } + + /// + /// Adds a footer representing that the action was performed in the . + /// + /// The builder to add the footer to. + /// The guild whose name and icon to use. + /// The builder with the added footer. + public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild) + { + var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); + var iconUrl = iconUrlResult.IsSuccess + ? iconUrlResult.Entity.AbsoluteUri + : default(Optional); + + return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl)); + } + + /// + /// Adds a title representing that the action happened in the . + /// + /// The builder to add the title to. + /// The guild whose name and icon to use. + /// The builder with the added title. + public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild) + { + var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); + var iconUrl = iconUrlResult.IsSuccess + ? iconUrlResult.Entity.AbsoluteUri + : null; + + builder.Author = new EmbedAuthorBuilder(guild.Name, iconUrl: iconUrl); + return builder; + } + + /// + /// Adds a scheduled event's cover image. + /// + /// The builder to add the image to. + /// The ID of the scheduled event whose image to use. + /// The Optional containing the image hash. + /// The builder with the added cover image. + public static EmbedBuilder WithEventCover( + this EmbedBuilder builder, Snowflake eventId, Optional imageHashOptional) + { + if (!imageHashOptional.IsDefined(out var imageHash)) + { + return builder; + } + + var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024); + return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder; + } +} diff --git a/src/Extensions/FeedbackServiceExtensions.cs b/src/Extensions/FeedbackServiceExtensions.cs new file mode 100644 index 00000000..401a8659 --- /dev/null +++ b/src/Extensions/FeedbackServiceExtensions.cs @@ -0,0 +1,19 @@ +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Results; + +namespace Octobot.Extensions; + +public static class FeedbackServiceExtensions +{ + public static async Task SendContextualEmbedResultAsync( + this FeedbackService feedback, Result embedResult, CancellationToken ct = default) + { + if (!embedResult.IsDefined(out var embed)) + { + return Result.FromError(embedResult); + } + + return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct); + } +} diff --git a/src/Extensions/GuildScheduledEventExtensions.cs b/src/Extensions/GuildScheduledEventExtensions.cs new file mode 100644 index 00000000..e3217e33 --- /dev/null +++ b/src/Extensions/GuildScheduledEventExtensions.cs @@ -0,0 +1,28 @@ +using Remora.Discord.API.Abstractions.Objects; +using Remora.Rest.Core; +using Remora.Results; + +namespace Octobot.Extensions; + +public static class GuildScheduledEventExtensions +{ + public static Result TryGetExternalEventData(this IGuildScheduledEvent scheduledEvent, out DateTimeOffset endTime, + out string? location) + { + endTime = default; + location = default; + if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) + { + return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)); + } + + if (!metadata.Location.IsDefined(out location)) + { + return new ArgumentNullError(nameof(metadata.Location)); + } + + return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime) + ? Result.FromSuccess() + : new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); + } +} diff --git a/src/Extensions/LoggerExtensions.cs b/src/Extensions/LoggerExtensions.cs new file mode 100644 index 00000000..fd4aeb7b --- /dev/null +++ b/src/Extensions/LoggerExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using Remora.Discord.Commands.Extensions; +using Remora.Results; + +namespace Octobot.Extensions; + +public static class LoggerExtensions +{ + /// + /// Checks if the has failed due to an error that has resulted from neither invalid user + /// input nor the execution environment and logs the error using the provided . + /// + /// + /// This has special behavior for - its exception will be passed to the + /// + /// + /// The logger to use. + /// The Result whose error check. + /// The message to use if this result has failed. + public static void LogResult(this ILogger logger, IResult result, string? message = "") + { + if (result.IsSuccess || result.Error.IsUserOrEnvironmentError()) + { + return; + } + + if (result.Error is ExceptionError exe) + { + logger.LogError(exe.Exception, "{ErrorMessage}", message); + return; + } + + logger.LogWarning("{UserMessage}\n{ResultErrorMessage}", message, result.Error.Message); + } +} diff --git a/src/Extensions/SnowflakeExtensions.cs b/src/Extensions/SnowflakeExtensions.cs new file mode 100644 index 00000000..e60bc44e --- /dev/null +++ b/src/Extensions/SnowflakeExtensions.cs @@ -0,0 +1,32 @@ +using Remora.Rest.Core; + +namespace Octobot.Extensions; + +public static class SnowflakeExtensions +{ + /// + /// Checks whether this Snowflake has any value set. + /// + /// The Snowflake to check. + /// true if the Snowflake has no value set or it's set to 0, false otherwise. + public static bool Empty(this Snowflake snowflake) + { + return snowflake.Value is 0; + } + + /// + /// Checks whether this snowflake is empty (see ) or it's equal to + /// + /// + /// The Snowflake to check for emptiness + /// The Snowflake to check for equality with . + /// + /// true if is empty or is equal to , false + /// otherwise. + /// + /// + public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake) + { + return snowflake.Empty() || snowflake == anotherSnowflake; + } +} diff --git a/src/Extensions/StringExtensions.cs b/src/Extensions/StringExtensions.cs new file mode 100644 index 00000000..3de05d3f --- /dev/null +++ b/src/Extensions/StringExtensions.cs @@ -0,0 +1,52 @@ +using System.Net; +using Remora.Discord.Extensions.Formatting; + +namespace Octobot.Extensions; + +public static class StringExtensions +{ + /// + /// Sanitizes a string for use in by inserting zero-width spaces in between + /// symbols used to format the string with block code. + /// + /// The string to sanitize. + /// The sanitized string that can be safely used in . + private static string SanitizeForBlockCode(this string s) + { + return s.Replace("```", "​`​`​`​"); + } + + /// + /// Sanitizes a string (see ) and formats the string to use Markdown Block Code + /// formatting with a specified + /// language for syntax highlighting. + /// + /// The string to sanitize and format. + /// + /// + /// The sanitized string formatted to use Markdown Block Code with a specified + /// language for syntax highlighting. + /// + public static string InBlockCode(this string s, string language = "") + { + s = s.SanitizeForBlockCode(); + return + $"```{language}\n{s.SanitizeForBlockCode()}{(s.EndsWith("`", StringComparison.Ordinal) || string.IsNullOrWhiteSpace(s) ? " " : "")}```"; + } + + public static string Localized(this string key) + { + return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key; + } + + /// + /// Encodes a string to allow its transmission in request headers. + /// + /// Used when encountering "Request headers must contain only ASCII characters". + /// The string to encode. + /// An encoded string with spaces kept intact. + public static string EncodeHeader(this string s) + { + return WebUtility.UrlEncode(s).Replace('+', ' '); + } +} diff --git a/src/Extensions/UInt64Extensions.cs b/src/Extensions/UInt64Extensions.cs new file mode 100644 index 00000000..5d1db00d --- /dev/null +++ b/src/Extensions/UInt64Extensions.cs @@ -0,0 +1,12 @@ +using Remora.Discord.API; +using Remora.Rest.Core; + +namespace Octobot.Extensions; + +public static class UInt64Extensions +{ + public static Snowflake ToSnowflake(this ulong id) + { + return DiscordSnowflake.New(id); + } +} diff --git a/src/Extensions/UserExtensions.cs b/src/Extensions/UserExtensions.cs new file mode 100644 index 00000000..38fe9859 --- /dev/null +++ b/src/Extensions/UserExtensions.cs @@ -0,0 +1,11 @@ +using Remora.Discord.API.Abstractions.Objects; + +namespace Octobot.Extensions; + +public static class UserExtensions +{ + public static string GetTag(this IUser user) + { + return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}"; + } +} diff --git a/src/Responders/GuildLoadedResponder.cs b/src/Responders/GuildLoadedResponder.cs index d78b69ab..78dcc431 100644 --- a/src/Responders/GuildLoadedResponder.cs +++ b/src/Responders/GuildLoadedResponder.cs @@ -1,6 +1,7 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Rest; diff --git a/src/Responders/GuildMemberJoinedResponder.cs b/src/Responders/GuildMemberJoinedResponder.cs index 57bd01f8..09075bf4 100644 --- a/src/Responders/GuildMemberJoinedResponder.cs +++ b/src/Responders/GuildMemberJoinedResponder.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Rest; diff --git a/src/Responders/MessageDeletedResponder.cs b/src/Responders/MessageDeletedResponder.cs index 92338203..5e4870b3 100644 --- a/src/Responders/MessageDeletedResponder.cs +++ b/src/Responders/MessageDeletedResponder.cs @@ -1,6 +1,7 @@ using System.Text; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; diff --git a/src/Responders/MessageEditedResponder.cs b/src/Responders/MessageEditedResponder.cs index 5e2084c4..7841b14c 100644 --- a/src/Responders/MessageEditedResponder.cs +++ b/src/Responders/MessageEditedResponder.cs @@ -2,6 +2,7 @@ using DiffPlex.DiffBuilder; using JetBrains.Annotations; using Octobot.Data; +using Octobot.Extensions; using Octobot.Services; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; diff --git a/src/Services/Update/MemberUpdateService.cs b/src/Services/Update/MemberUpdateService.cs index 6614273c..ae099f66 100644 --- a/src/Services/Update/MemberUpdateService.cs +++ b/src/Services/Update/MemberUpdateService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Octobot.Data; +using Octobot.Extensions; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.Extensions.Embeds; diff --git a/src/Services/Update/ScheduledEventUpdateService.cs b/src/Services/Update/ScheduledEventUpdateService.cs index 7653d2bf..20d23fa8 100644 --- a/src/Services/Update/ScheduledEventUpdateService.cs +++ b/src/Services/Update/ScheduledEventUpdateService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Octobot.Data; +using Octobot.Extensions; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; diff --git a/src/Services/UtilityService.cs b/src/Services/UtilityService.cs index 22e38cbb..b144ca74 100644 --- a/src/Services/UtilityService.cs +++ b/src/Services/UtilityService.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using Microsoft.Extensions.Hosting; using Octobot.Data; +using Octobot.Extensions; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.Extensions.Embeds;