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

Split extension methods into separate classes (#161)

This PR splits the extension methods contained in `Extensions.cs` into
separate classes in the `Octobot.Extensions` namespace. This was done
for multiple reasons:
1) The `Extensions.cs` violates SRP (Single Responsibility Principle) -
it takes upon itself every extension method for many types
2) Having a separate class for each extended type is a standard practice
- take a look at
[Remora.Discord](https://github.com/Remora/Remora.Discord/tree/main/Backend/Remora.Discord.Rest/Extensions)
or [osu!](https://github.com/ppy/osu/tree/master/osu.Game/Extensions)
3) Having all extension methods in one file makes it hard to find the
method you want
This commit is contained in:
Octol1ttle 2023-10-12 20:37:25 +05:00 committed by GitHub
parent 20eac79380
commit e6f53b13f0
Signed by: GitHub
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 452 additions and 371 deletions

View file

@ -2,6 +2,7 @@
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;

View file

@ -2,6 +2,7 @@ using System.ComponentModel;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Octobot.Services.Update; using Octobot.Services.Update;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
@ -53,7 +54,7 @@ public class BanCommandGroup : CommandGroup
/// <param name="target">The user to ban.</param> /// <param name="target">The user to ban.</param>
/// <param name="duration">The duration for this ban. The user will be automatically unbanned after this duration.</param> /// <param name="duration">The duration for this ban. The user will be automatically unbanned after this duration.</param>
/// <param name="reason"> /// <param name="reason">
/// The reason for this ban. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to /// The reason for this ban. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.CreateGuildBanAsync" />. /// <see cref="IDiscordRestGuildAPI.CreateGuildBanAsync" />.
/// </param> /// </param>
/// <returns> /// <returns>
@ -196,7 +197,7 @@ public class BanCommandGroup : CommandGroup
/// </summary> /// </summary>
/// <param name="target">The user to unban.</param> /// <param name="target">The user to unban.</param>
/// <param name="reason"> /// <param name="reason">
/// The reason for this unban. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to /// The reason for this unban. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.RemoveGuildBanAsync" />. /// <see cref="IDiscordRestGuildAPI.RemoveGuildBanAsync" />.
/// </param> /// </param>
/// <returns> /// <returns>

View file

@ -2,6 +2,7 @@ using System.ComponentModel;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;

View file

@ -1,5 +1,6 @@
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Extensions;
using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Services; using Remora.Discord.Commands.Services;
using Remora.Results; using Remora.Results;

View file

@ -1,5 +1,6 @@
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Extensions;
using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Services; using Remora.Discord.Commands.Services;
using Remora.Results; using Remora.Results;

View file

@ -1,6 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
@ -49,7 +50,7 @@ public class KickCommandGroup : CommandGroup
/// </summary> /// </summary>
/// <param name="target">The member to kick.</param> /// <param name="target">The member to kick.</param>
/// <param name="reason"> /// <param name="reason">
/// The reason for this kick. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to /// The reason for this kick. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.RemoveGuildMemberAsync" />. /// <see cref="IDiscordRestGuildAPI.RemoveGuildMemberAsync" />.
/// </param> /// </param>
/// <returns> /// <returns>

View file

@ -2,6 +2,7 @@ using System.ComponentModel;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Octobot.Services.Update; using Octobot.Services.Update;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
@ -50,7 +51,7 @@ public class MuteCommandGroup : CommandGroup
/// <param name="target">The member to mute.</param> /// <param name="target">The member to mute.</param>
/// <param name="duration">The duration for this mute. The member will be automatically unmuted after this duration.</param> /// <param name="duration">The duration for this mute. The member will be automatically unmuted after this duration.</param>
/// <param name="reason"> /// <param name="reason">
/// The reason for this mute. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to /// The reason for this mute. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />. /// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />.
/// </param> /// </param>
/// <returns> /// <returns>
@ -213,7 +214,7 @@ public class MuteCommandGroup : CommandGroup
/// </summary> /// </summary>
/// <param name="target">The member to unmute.</param> /// <param name="target">The member to unmute.</param>
/// <param name="reason"> /// <param name="reason">
/// The reason for this unmute. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to /// The reason for this unmute. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />. /// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />.
/// </param> /// </param>
/// <returns> /// <returns>

View file

@ -1,6 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;

View file

@ -2,6 +2,7 @@ using System.ComponentModel;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;

View file

@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Data.Options; using Octobot.Data.Options;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;

View file

@ -3,6 +3,7 @@ using System.Drawing;
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Commands.Attributes; using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;

View file

@ -1,5 +1,6 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Octobot.Extensions;
using Remora.Discord.Extensions.Formatting; using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;

View file

@ -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
{
/// <summary>
/// Adds a footer representing that an action was performed by a <paramref name="user" />.
/// </summary>
/// <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>
/// <returns>The builder with the added footer.</returns>
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));
}
/// <summary>
/// Adds a title using the author field, making it smaller than using the title field.
/// </summary>
/// <param name="builder">The builder to add the small title to.</param>
/// <param name="text">The text of 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>
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;
}
/// <summary>
/// Adds a user avatar in the thumbnail field.
/// </summary>
/// <param name="builder">The builder to add the thumbnail to.</param>
/// <param name="avatarSource">The user whose avatar to use in the thumbnail field.</param>
/// <returns>The builder with the added avatar in the thumbnail field.</returns>
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);
}
/// <summary>
/// Adds a guild icon in the thumbnail field.
/// </summary>
/// <param name="builder">The builder to add the thumbnail to.</param>
/// <param name="iconSource">The guild whose icon to use in the thumbnail field.</param>
/// <returns>The builder with the added icon in the thumbnail field.</returns>
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;
}
/// <summary>
/// Adds a guild banner in the image field.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="bannerSource">The guild whose banner to use in the image field.</param>
/// <returns>The builder with the added banner in the image field.</returns>
public static EmbedBuilder WithGuildBanner(
this EmbedBuilder builder, IGuild bannerSource)
{
return bannerSource.Banner is not null
? builder.WithImageUrl(CDN.GetGuildBannerUrl(bannerSource).Entity.AbsoluteUri)
: builder;
}
/// <summary>
/// Adds a footer representing that the action was performed in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added footer.</returns>
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<string>);
return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl));
}
/// <summary>
/// Adds a title representing that the action happened in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the title to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added title.</returns>
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;
}
/// <summary>
/// Adds a scheduled event's cover image.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="eventId">The ID of the scheduled event whose image to use.</param>
/// <param name="imageHashOptional">The Optional containing the image hash.</param>
/// <returns>The builder with the added cover image.</returns>
public static EmbedBuilder WithEventCover(
this EmbedBuilder builder, Snowflake eventId, Optional<IImageHash?> 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;
}
/// <summary>
/// Sanitizes a string for use in <see cref="Markdown.BlockCode(string)" /> by inserting zero-width spaces in between
/// symbols used to format the string with block code.
/// </summary>
/// <param name="s">The string to sanitize.</param>
/// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns>
private static string SanitizeForBlockCode(this string s)
{
return s.Replace("```", "```");
}
/// <summary>
/// Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string to use Markdown Block Code
/// formatting with a specified
/// language for syntax highlighting.
/// </summary>
/// <param name="s">The string to sanitize and format.</param>
/// <param name="language"></param>
/// <returns>
/// The sanitized string formatted to use Markdown Block Code with a specified
/// language for syntax highlighting.
/// </returns>
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;
}
/// <summary>
/// Encodes a string to allow its transmission in request headers.
/// </summary>
/// <remarks>Used when encountering "Request headers must contain only ASCII characters".</remarks>
/// <param name="s">The string to encode.</param>
/// <returns>An encoded string with spaces kept intact.</returns>
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<TSource, TResult>(
this IEnumerable<TSource> source, Func<TSource, TResult> 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);
}
/// <summary>
/// Checks whether this Snowflake has any value set.
/// </summary>
/// <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>
public static bool Empty(this Snowflake snowflake)
{
return snowflake.Value is 0;
}
/// <summary>
/// Checks whether this snowflake is empty (see <see cref="Empty" />) or it's equal to
/// <paramref name="anotherSnowflake" />
/// </summary>
/// <param name="snowflake">The Snowflake to check for emptiness</param>
/// <param name="anotherSnowflake">The Snowflake to check for equality with <paramref name="snowflake" />.</param>
/// <returns>
/// true if <paramref name="snowflake" /> is empty or is equal to <paramref name="anotherSnowflake" />, false
/// otherwise.
/// </returns>
/// <seealso cref="Empty" />
public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake)
{
return snowflake.Empty() || snowflake == anotherSnowflake;
}
public static async Task<Result> SendContextualEmbedResultAsync(
this FeedbackService feedback, Result<Embed> embedResult, CancellationToken ct = default)
{
if (!embedResult.IsDefined(out var embed))
{
return Result.FromError(embedResult);
}
return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct);
}
/// <summary>
/// Checks if the <paramref name="result" /> 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 <paramref name="logger" />.
/// </summary>
/// <remarks>
/// This has special behavior for <see cref="ExceptionError" /> - its exception will be passed to the
/// <paramref name="logger" />
/// </remarks>
/// <param name="logger">The logger to use.</param>
/// <param name="result">The Result whose error check.</param>
/// <param name="message">The message to use if this result has failed.</param>
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<Result> list, Result result)
{
if (!result.IsSuccess)
{
list.Add(result);
}
}
/// <summary>
/// Return an appropriate result for a list of failed results. The list must only contain failed results.
/// </summary>
/// <param name="list">The list of failed results.</param>
/// <returns>
/// A successful result if the list is empty, the only Result in the list, or <see cref="AggregateError" />
/// containing all results from the list.
/// </returns>
/// <exception cref="InvalidOperationException"></exception>
public static Result AggregateErrors(this List<Result> list)
{
return list.Count switch
{
0 => Result.FromSuccess(),
1 => list[0],
_ => new AggregateError(list.Cast<IResult>().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));
}
}

View file

@ -0,0 +1,40 @@
using Remora.Results;
namespace Octobot.Extensions;
public static class CollectionExtensions
{
public static TResult? MaxOrDefault<TSource, TResult>(
this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
var list = source.ToList();
return list.Any() ? list.Max(selector) : default;
}
public static void AddIfFailed(this List<Result> list, Result result)
{
if (!result.IsSuccess)
{
list.Add(result);
}
}
/// <summary>
/// Return an appropriate result for a list of failed results. The list must only contain failed results.
/// </summary>
/// <param name="list">The list of failed results.</param>
/// <returns>
/// A successful result if the list is empty, the only Result in the list, or <see cref="AggregateError" />
/// containing all results from the list.
/// </returns>
/// <exception cref="InvalidOperationException"></exception>
public static Result AggregateErrors(this List<Result> list)
{
return list.Count switch
{
0 => Result.FromSuccess(),
1 => list[0],
_ => new AggregateError(list.Cast<IResult>().ToArray())
};
}
}

View file

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

View file

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

View file

@ -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
{
/// <summary>
/// Adds a footer representing that an action was performed by a <paramref name="user" />.
/// </summary>
/// <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>
/// <returns>The builder with the added footer.</returns>
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));
}
/// <summary>
/// Adds a title using the author field, making it smaller than using the title field.
/// </summary>
/// <param name="builder">The builder to add the small title to.</param>
/// <param name="text">The text of 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>
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;
}
/// <summary>
/// Adds a user avatar in the thumbnail field.
/// </summary>
/// <param name="builder">The builder to add the thumbnail to.</param>
/// <param name="avatarSource">The user whose avatar to use in the thumbnail field.</param>
/// <returns>The builder with the added avatar in the thumbnail field.</returns>
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);
}
/// <summary>
/// Adds a guild icon in the thumbnail field.
/// </summary>
/// <param name="builder">The builder to add the thumbnail to.</param>
/// <param name="iconSource">The guild whose icon to use in the thumbnail field.</param>
/// <returns>The builder with the added icon in the thumbnail field.</returns>
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;
}
/// <summary>
/// Adds a guild banner in the image field.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="bannerSource">The guild whose banner to use in the image field.</param>
/// <returns>The builder with the added banner in the image field.</returns>
public static EmbedBuilder WithGuildBanner(
this EmbedBuilder builder, IGuild bannerSource)
{
return bannerSource.Banner is not null
? builder.WithImageUrl(CDN.GetGuildBannerUrl(bannerSource).Entity.AbsoluteUri)
: builder;
}
/// <summary>
/// Adds a footer representing that the action was performed in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added footer.</returns>
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<string>);
return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl));
}
/// <summary>
/// Adds a title representing that the action happened in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the title to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added title.</returns>
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;
}
/// <summary>
/// Adds a scheduled event's cover image.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="eventId">The ID of the scheduled event whose image to use.</param>
/// <param name="imageHashOptional">The Optional containing the image hash.</param>
/// <returns>The builder with the added cover image.</returns>
public static EmbedBuilder WithEventCover(
this EmbedBuilder builder, Snowflake eventId, Optional<IImageHash?> 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;
}
}

View file

@ -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<Result> SendContextualEmbedResultAsync(
this FeedbackService feedback, Result<Embed> embedResult, CancellationToken ct = default)
{
if (!embedResult.IsDefined(out var embed))
{
return Result.FromError(embedResult);
}
return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct);
}
}

View file

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

View file

@ -0,0 +1,35 @@
using Microsoft.Extensions.Logging;
using Remora.Discord.Commands.Extensions;
using Remora.Results;
namespace Octobot.Extensions;
public static class LoggerExtensions
{
/// <summary>
/// Checks if the <paramref name="result" /> 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 <paramref name="logger" />.
/// </summary>
/// <remarks>
/// This has special behavior for <see cref="ExceptionError" /> - its exception will be passed to the
/// <paramref name="logger" />
/// </remarks>
/// <param name="logger">The logger to use.</param>
/// <param name="result">The Result whose error check.</param>
/// <param name="message">The message to use if this result has failed.</param>
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);
}
}

View file

@ -0,0 +1,32 @@
using Remora.Rest.Core;
namespace Octobot.Extensions;
public static class SnowflakeExtensions
{
/// <summary>
/// Checks whether this Snowflake has any value set.
/// </summary>
/// <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>
public static bool Empty(this Snowflake snowflake)
{
return snowflake.Value is 0;
}
/// <summary>
/// Checks whether this snowflake is empty (see <see cref="Empty" />) or it's equal to
/// <paramref name="anotherSnowflake" />
/// </summary>
/// <param name="snowflake">The Snowflake to check for emptiness</param>
/// <param name="anotherSnowflake">The Snowflake to check for equality with <paramref name="snowflake" />.</param>
/// <returns>
/// true if <paramref name="snowflake" /> is empty or is equal to <paramref name="anotherSnowflake" />, false
/// otherwise.
/// </returns>
/// <seealso cref="Empty" />
public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake)
{
return snowflake.Empty() || snowflake == anotherSnowflake;
}
}

View file

@ -0,0 +1,52 @@
using System.Net;
using Remora.Discord.Extensions.Formatting;
namespace Octobot.Extensions;
public static class StringExtensions
{
/// <summary>
/// Sanitizes a string for use in <see cref="Markdown.BlockCode(string)" /> by inserting zero-width spaces in between
/// symbols used to format the string with block code.
/// </summary>
/// <param name="s">The string to sanitize.</param>
/// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns>
private static string SanitizeForBlockCode(this string s)
{
return s.Replace("```", "```");
}
/// <summary>
/// Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string to use Markdown Block Code
/// formatting with a specified
/// language for syntax highlighting.
/// </summary>
/// <param name="s">The string to sanitize and format.</param>
/// <param name="language"></param>
/// <returns>
/// The sanitized string formatted to use Markdown Block Code with a specified
/// language for syntax highlighting.
/// </returns>
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;
}
/// <summary>
/// Encodes a string to allow its transmission in request headers.
/// </summary>
/// <remarks>Used when encountering "Request headers must contain only ASCII characters".</remarks>
/// <param name="s">The string to encode.</param>
/// <returns>An encoded string with spaces kept intact.</returns>
public static string EncodeHeader(this string s)
{
return WebUtility.UrlEncode(s).Replace('+', ' ');
}
}

View file

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

View file

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

View file

@ -1,6 +1,7 @@
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;

View file

@ -1,6 +1,7 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;

View file

@ -1,6 +1,7 @@
using System.Text; using System.Text;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;

View file

@ -2,6 +2,7 @@ using System.Text;
using DiffPlex.DiffBuilder; using DiffPlex.DiffBuilder;
using JetBrains.Annotations; using JetBrains.Annotations;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services; using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;

View file

@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;

View file

@ -2,6 +2,7 @@ using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;

View file

@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Octobot.Data; using Octobot.Data;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;