Restructured the project structure back to the way it was, there's no reasonable way to split the modules

This commit is contained in:
Kwoth
2024-04-26 22:26:24 +00:00
parent 6c9c8bf63e
commit e0819f760c
768 changed files with 192 additions and 1047 deletions

View File

@@ -0,0 +1,10 @@
namespace NadekoBot.Extensions;
public static class BotCredentialsExtensions
{
public static bool IsOwner(this IBotCredentials creds, IUser user)
=> creds.IsOwner(user.Id);
public static bool IsOwner(this IBotCredentials creds, ulong userId)
=> creds.OwnerIds.Contains(userId);
}

View File

@@ -0,0 +1,207 @@
using Humanizer.Localisation;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Nadeko.Common.Medusa;
namespace NadekoBot.Extensions;
public static class Extensions
{
public static DateOnly ToDateOnly(this DateTime dateTime)
=> DateOnly.FromDateTime(dateTime);
public static bool IsBeforeToday(this DateTime date)
=> date < DateTime.UtcNow.Date;
private static readonly Regex _urlRegex =
new(@"^(https?|ftp)://(?<path>[^\s/$.?#].[^\s]*)$", RegexOptions.Compiled);
public static IEmbedBuilder WithAuthor(this IEmbedBuilder eb, IUser author)
=> eb.WithAuthor(author.ToString()!, author.RealAvatarUrl().ToString());
public static Task EditAsync(this IUserMessage msg, SmartText text)
=> text switch
{
SmartEmbedText set => msg.ModifyAsync(x =>
{
x.Embed = set.IsValid ? set.GetEmbed().Build() : null;
x.Content = set.PlainText?.SanitizeMentions() ?? "";
}),
SmartEmbedTextArray set => msg.ModifyAsync(x =>
{
x.Embeds = set.GetEmbedBuilders().Map(eb => eb.Build());
x.Content = set.Content?.SanitizeMentions() ?? "";
}),
SmartPlainText spt => msg.ModifyAsync(x =>
{
x.Content = spt.Text.SanitizeMentions();
x.Embed = null;
}),
_ => throw new ArgumentOutOfRangeException(nameof(text))
};
public static ulong[] GetGuildIds(this DiscordSocketClient client)
=> client.Guilds
.Map(x => x.Id);
/// <summary>
/// Generates a string in the format HHH:mm if timespan is &gt;= 2m.
/// Generates a string in the format 00:mm:ss if timespan is less than 2m.
/// </summary>
/// <param name="span">Timespan to convert to string</param>
/// <returns>Formatted duration string</returns>
public static string ToPrettyStringHm(this TimeSpan span)
=> span.Humanize(2, minUnit: TimeUnit.Second);
public static bool TryGetUrlPath(this string input, out string path)
{
var match = _urlRegex.Match(input);
if (match.Success)
{
path = match.Groups["path"].Value;
return true;
}
path = string.Empty;
return false;
}
public static IEmote ToIEmote(this string emojiStr)
=> Emote.TryParse(emojiStr, out var maybeEmote) ? maybeEmote : new Emoji(emojiStr);
/// <summary>
/// First 10 characters of teh bot token.
/// </summary>
public static string RedisKey(this IBotCredentials bc)
=> bc.Token[..10];
public static bool IsAuthor(this IMessage msg, IDiscordClient client)
=> msg.Author?.Id == client.CurrentUser.Id;
public static string RealSummary(
this CommandInfo cmd,
IBotStrings strings,
IMedusaLoaderService medusae,
CultureInfo culture,
string prefix)
{
string description;
if (cmd.Remarks?.StartsWith("medusa///") ?? false)
{
// command method name is kept in Summary
// medusa///<medusa-name-here> is kept in remarks
// this way I can find the name of the medusa, and then name of the command for which
// the description should be loaded
var medusaName = cmd.Remarks.Split("///")[1];
description = medusae.GetCommandDescription(medusaName, cmd.Summary, culture);
}
else
{
description = strings.GetCommandStrings(cmd.Summary, culture).Desc;
}
return string.Format(description, prefix);
}
public static string[] RealRemarksArr(
this CommandInfo cmd,
IBotStrings strings,
IMedusaLoaderService medusae,
CultureInfo culture,
string prefix)
{
string[] args;
if (cmd.Remarks?.StartsWith("medusa///") ?? false)
{
// command method name is kept in Summary
// medusa///<medusa-name-here> is kept in remarks
// this way I can find the name of the medusa,
// and command for which data should be loaded
var medusaName = cmd.Remarks.Split("///")[1];
args = medusae.GetCommandExampleArgs(medusaName, cmd.Summary, culture);
}
else
{
args = strings.GetCommandStrings(cmd.Summary, culture).Args;
}
return args.Map(arg => GetFullUsage(cmd.Aliases.First(), arg, prefix));
}
private static string GetFullUsage(string commandName, string args, string prefix)
=> $"{prefix}{commandName} {string.Format(args, prefix)}".TrimEnd();
public static IEmbedBuilder AddPaginatedFooter(this IEmbedBuilder embed, int curPage, int? lastPage)
{
if (lastPage is not null)
return embed.WithFooter($"{curPage + 1} / {lastPage + 1}");
return embed.WithFooter(curPage.ToString());
}
public static IEmbedBuilder WithOkColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Ok);
public static IEmbedBuilder WithPendingColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Pending);
public static IEmbedBuilder WithErrorColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Error);
public static IMessage DeleteAfter(this IUserMessage msg, float seconds, ILogCommandService? logService = null)
{
Task.Run(async () =>
{
await Task.Delay((int)(seconds * 1000));
if (logService is not null)
logService.AddDeleteIgnore(msg.Id);
try
{
await msg.DeleteAsync();
}
catch
{
}
});
return msg;
}
public static ModuleInfo GetTopLevelModule(this ModuleInfo module)
{
while (module.Parent is not null)
module = module.Parent;
return module;
}
public static string GetGroupName(this ModuleInfo module)
=> module.Name.Replace("Commands", "", StringComparison.InvariantCulture);
public static async Task<IEnumerable<IGuildUser>> GetMembersAsync(this IRole role)
{
var users = await role.Guild.GetUsersAsync(CacheMode.CacheOnly);
return users.Where(u => u.RoleIds.Contains(role.Id));
}
public static string ToJson<T>(this T any, JsonSerializerOptions? options = null)
=> JsonSerializer.Serialize(any, options);
public static Stream ToStream(this IEnumerable<byte> bytes, bool canWrite = false)
{
var ms = new MemoryStream(bytes as byte[] ?? bytes.ToArray(), canWrite);
ms.Seek(0, SeekOrigin.Begin);
return ms;
}
public static IEnumerable<IRole> GetRoles(this IGuildUser user)
=> user.RoleIds.Select(r => user.Guild.GetRole(r)).Where(r => r is not null);
public static void Lap(this Stopwatch sw, string checkpoint)
{
Log.Information("Checkpoint {CheckPoint}: {Time}ms", checkpoint, sw.Elapsed.TotalMilliseconds);
sw.Restart();
}
}

View File

@@ -0,0 +1,329 @@
namespace NadekoBot.Extensions;
public static class MessageChannelExtensions
{
// main overload that all other send methods reduce to
public static Task<IUserMessage> SendAsync(
this IMessageChannel channel,
string? plainText,
Embed? embed = null,
IReadOnlyCollection<Embed>? embeds = null,
bool sanitizeAll = false,
MessageComponent? components = null)
{
plainText = sanitizeAll
? plainText?.SanitizeAllMentions() ?? ""
: plainText?.SanitizeMentions() ?? "";
return channel.SendMessageAsync(plainText,
embed: embed,
embeds: embeds is null
? null
: embeds as Embed[] ?? embeds.ToArray(),
components: components);
}
public static async Task<IUserMessage> SendAsync(
this IMessageChannel channel,
string? plainText,
NadekoInteraction? inter,
Embed? embed = null,
IReadOnlyCollection<Embed>? embeds = null,
bool sanitizeAll = false)
{
var msg = await channel.SendAsync(plainText,
embed,
embeds,
sanitizeAll,
inter?.CreateComponent());
if (inter is not null)
await inter.RunAsync(msg);
return msg;
}
public static Task<IUserMessage> SendAsync(
this IMessageChannel channel,
SmartText text,
bool sanitizeAll = false)
=> text switch
{
SmartEmbedText set => channel.SendAsync(set.PlainText,
set.IsValid ? set.GetEmbed().Build() : null,
sanitizeAll: sanitizeAll),
SmartPlainText st => channel.SendAsync(st.Text,
default(Embed),
sanitizeAll: sanitizeAll),
SmartEmbedTextArray arr => channel.SendAsync(arr.Content,
embeds: arr.GetEmbedBuilders().Map(e => e.Build())),
_ => throw new ArgumentOutOfRangeException(nameof(text))
};
public static Task<IUserMessage> EmbedAsync(
this IMessageChannel ch,
IEmbedBuilder? embed,
string plainText = "",
IReadOnlyCollection<IEmbedBuilder>? embeds = null,
NadekoInteraction? inter = null)
=> ch.SendAsync(plainText,
inter,
embed: embed?.Build(),
embeds: embeds?.Map(x => x.Build()));
public static Task<IUserMessage> SendAsync(
this IMessageChannel ch,
IEmbedBuilderService eb,
string text,
MsgType type,
NadekoInteraction? inter = null)
{
var builder = eb.Create().WithDescription(text);
builder = (type switch
{
MsgType.Error => builder.WithErrorColor(),
MsgType.Ok => builder.WithOkColor(),
MsgType.Pending => builder.WithPendingColor(),
_ => throw new ArgumentOutOfRangeException(nameof(type))
});
return ch.EmbedAsync(builder, inter: inter);
}
public static Task<IUserMessage> SendConfirmAsync(this IMessageChannel ch, IEmbedBuilderService eb, string text)
=> ch.SendAsync(eb, text, MsgType.Ok);
public static Task<IUserMessage> SendAsync(
this IMessageChannel ch,
IEmbedBuilderService eb,
MsgType type,
string? title,
string text,
string? url = null,
string? footer = null)
{
var embed = eb.Create()
.WithDescription(text)
.WithTitle(title);
if (url is not null && Uri.IsWellFormedUriString(url, UriKind.Absolute))
embed.WithUrl(url);
if (!string.IsNullOrWhiteSpace(footer))
embed.WithFooter(footer);
embed = type switch
{
MsgType.Error => embed.WithErrorColor(),
MsgType.Ok => embed.WithOkColor(),
MsgType.Pending => embed.WithPendingColor(),
_ => throw new ArgumentOutOfRangeException(nameof(type))
};
return ch.EmbedAsync(embed);
}
// embed title and optional footer overloads
public static Task<IUserMessage> SendConfirmAsync(
this IMessageChannel ch,
IEmbedBuilderService eb,
string? title,
string text,
string? url = null,
string? footer = null)
=> ch.SendAsync(eb, MsgType.Ok, title, text, url, footer);
public static Task<IUserMessage> SendErrorAsync(
this IMessageChannel ch,
IEmbedBuilderService eb,
string title,
string text,
string? url = null,
string? footer = null)
=> ch.SendAsync(eb, MsgType.Error, title, text, url, footer);
// regular send overloads
public static Task<IUserMessage> SendErrorAsync(this IMessageChannel ch, IEmbedBuilderService eb, string text)
=> ch.SendAsync(eb, text, MsgType.Error);
public static Task SendPaginatedConfirmAsync(
this ICommandContext ctx,
int currentPage,
Func<int, IEmbedBuilder> pageFunc,
int totalElements,
int itemsPerPage,
bool addPaginatedFooter = true)
=> ctx.SendPaginatedConfirmAsync(currentPage,
x => Task.FromResult(pageFunc(x)),
totalElements,
itemsPerPage,
addPaginatedFooter);
private const string BUTTON_LEFT = "BUTTON_LEFT";
private const string BUTTON_RIGHT = "BUTTON_RIGHT";
private static readonly IEmote _arrowLeft = Emote.Parse("<:x:1232256519844790302>");
private static readonly IEmote _arrowRight = Emote.Parse("<:x:1232256515298295838>");
public static Task SendPaginatedConfirmAsync(
this ICommandContext ctx,
int currentPage,
Func<int, Task<IEmbedBuilder>> pageFunc,
int totalElements,
int itemsPerPage,
bool addPaginatedFooter = true)
=> ctx.SendPaginatedConfirmAsync(currentPage,
pageFunc,
default(Func<int, ValueTask<SimpleInteraction<object>?>>),
totalElements,
itemsPerPage,
addPaginatedFooter);
public static async Task SendPaginatedConfirmAsync<T>(
this ICommandContext ctx,
int currentPage,
Func<int, Task<IEmbedBuilder>> pageFunc,
Func<int, ValueTask<SimpleInteraction<T>?>>? interFactory,
int totalElements,
int itemsPerPage,
bool addPaginatedFooter = true)
{
var lastPage = (totalElements - 1) / itemsPerPage;
var embed = await pageFunc(currentPage);
if (addPaginatedFooter)
embed.AddPaginatedFooter(currentPage, lastPage);
SimpleInteraction<T>? maybeInter = null;
async Task<ComponentBuilder> GetComponentBuilder()
{
var cb = new ComponentBuilder();
cb.WithButton(new ButtonBuilder()
.WithStyle(ButtonStyle.Primary)
.WithCustomId(BUTTON_LEFT)
.WithDisabled(lastPage == 0)
.WithEmote(_arrowLeft)
.WithDisabled(currentPage <= 0));
if (interFactory is not null)
{
maybeInter = await interFactory(currentPage);
if (maybeInter is not null)
cb.WithButton(maybeInter.Button);
}
cb.WithButton(new ButtonBuilder()
.WithStyle(ButtonStyle.Primary)
.WithCustomId(BUTTON_RIGHT)
.WithDisabled(lastPage == 0 || currentPage >= lastPage)
.WithEmote(_arrowRight));
return cb;
}
async Task UpdatePageAsync(SocketMessageComponent smc)
{
var toSend = await pageFunc(currentPage);
if (addPaginatedFooter)
toSend.AddPaginatedFooter(currentPage, lastPage);
var component = (await GetComponentBuilder()).Build();
await smc.ModifyOriginalResponseAsync(x =>
{
x.Embed = toSend.Build();
x.Components = component;
});
}
var component = (await GetComponentBuilder()).Build();
var msg = await ctx.Channel.SendAsync(null, embed: embed.Build(), components: component);
async Task OnInteractionAsync(SocketInteraction si)
{
try
{
if (si is not SocketMessageComponent smc)
return;
if (smc.Message.Id != msg.Id)
return;
await si.DeferAsync();
if (smc.User.Id != ctx.User.Id)
return;
if (smc.Data.CustomId == BUTTON_LEFT)
{
if (currentPage == 0)
return;
--currentPage;
_ = UpdatePageAsync(smc);
}
else if (smc.Data.CustomId == BUTTON_RIGHT)
{
if (currentPage >= lastPage)
return;
++currentPage;
_ = UpdatePageAsync(smc);
}
else if (maybeInter is { } inter && inter.Button.CustomId == smc.Data.CustomId)
{
await inter.TriggerAsync(smc);
_ = UpdatePageAsync(smc);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message);
}
}
if (lastPage == 0 && interFactory is null)
return;
var client = (DiscordSocketClient)ctx.Client;
client.InteractionCreated += OnInteractionAsync;
await Task.Delay(30_000);
client.InteractionCreated -= OnInteractionAsync;
await msg.ModifyAsync(mp => mp.Components = new ComponentBuilder().Build());
}
private static readonly Emoji _okEmoji = new Emoji("✅");
private static readonly Emoji _warnEmoji = new Emoji("⚠️");
private static readonly Emoji _errorEmoji = new Emoji("❌");
public static Task ReactAsync(this ICommandContext ctx, MsgType type)
{
var emoji = type switch
{
MsgType.Error => _errorEmoji,
MsgType.Pending => _warnEmoji,
MsgType.Ok => _okEmoji,
_ => throw new ArgumentOutOfRangeException(nameof(type)),
};
return ctx.Message.AddReactionAsync(emoji);
}
public static Task OkAsync(this ICommandContext ctx)
=> ctx.ReactAsync(MsgType.Ok);
public static Task ErrorAsync(this ICommandContext ctx)
=> ctx.ReactAsync(MsgType.Error);
public static Task WarningAsync(this ICommandContext ctx)
=> ctx.ReactAsync(MsgType.Pending);
}

View File

@@ -0,0 +1,18 @@
namespace NadekoBot.Extensions;
public static class LinkedListExtensions
{
public static LinkedListNode<T>? FindNode<T>(this LinkedList<T> list, Func<T, bool> predicate)
{
var node = list.First;
while (node is not null)
{
if (predicate(node.Value))
return node;
node = node.Next;
}
return null;
}
}

View File

@@ -0,0 +1,7 @@
namespace NadekoBot.Extensions;
public static class NumberExtensions
{
public static DateTimeOffset ToUnixTimestamp(this double number)
=> new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(number);
}

View File

@@ -0,0 +1,23 @@
namespace NadekoBot.Extensions;
public static class ReflectionExtensions
{
public static bool IsAssignableToGenericType(this Type givenType, Type genericType)
{
var interfaceTypes = givenType.GetInterfaces();
foreach (var it in interfaceTypes)
{
if (it.IsGenericType && it.GetGenericTypeDefinition() == genericType)
return true;
}
if (givenType.IsGenericType && givenType.GetGenericTypeDefinition() == genericType)
return true;
var baseType = givenType.BaseType;
if (baseType == null) return false;
return IsAssignableToGenericType(baseType, genericType);
}
}

View File

@@ -0,0 +1,57 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace NadekoBot.Extensions;
public static class Rgba32Extensions
{
public static Image<Rgba32> Merge(this IEnumerable<Image<Rgba32>> images)
=> images.Merge(out _);
public static Image<Rgba32> Merge(this IEnumerable<Image<Rgba32>> images, out IImageFormat format)
{
format = PngFormat.Instance;
void DrawFrame(IList<Image<Rgba32>> imgArray, Image<Rgba32> imgFrame, int frameNumber)
{
var xOffset = 0;
for (var i = 0; i < imgArray.Count; i++)
{
using var frame = imgArray[i].Frames.CloneFrame(frameNumber % imgArray[i].Frames.Count);
var offset = xOffset;
imgFrame.Mutate(x => x.DrawImage(frame, new Point(offset, 0), new GraphicsOptions()));
xOffset += imgArray[i].Bounds().Width;
}
}
var imgs = images.ToList();
var frames = imgs.Max(x => x.Frames.Count);
var width = imgs.Sum(img => img.Width);
var height = imgs.Max(img => img.Height);
var canvas = new Image<Rgba32>(width, height);
if (frames == 1)
{
DrawFrame(imgs, canvas, 0);
return canvas;
}
format = GifFormat.Instance;
for (var j = 0; j < frames; j++)
{
using var imgFrame = new Image<Rgba32>(width, height);
DrawFrame(imgs, imgFrame, j);
var frameToAdd = imgFrame.Frames[0];
frameToAdd.Metadata.GetGifMetadata().DisposalMethod = GifDisposalMethod.RestoreToBackground;
canvas.Frames.AddFrame(frameToAdd);
}
canvas.Frames.RemoveFrame(0);
return canvas;
}
}

View File

@@ -0,0 +1,97 @@

namespace NadekoBot.Extensions;
public static class SocketMessageComponentExtensions
{
public static Task RespondAsync(
this SocketMessageComponent smc,
string? plainText,
Embed? embed = null,
IReadOnlyCollection<Embed>? embeds = null,
bool sanitizeAll = false,
MessageComponent? components = null,
bool ephemeral = true)
{
plainText = sanitizeAll
? plainText?.SanitizeAllMentions() ?? ""
: plainText?.SanitizeMentions() ?? "";
return smc.RespondAsync(plainText,
embed: embed,
embeds: embeds is null
? null
: embeds as Embed[] ?? embeds.ToArray(),
components: components,
ephemeral: ephemeral);
}
public static Task RespondAsync(
this SocketMessageComponent smc,
SmartText text,
bool sanitizeAll = false,
bool ephemeral = true)
=> text switch
{
SmartEmbedText set => smc.RespondAsync(set.PlainText,
set.IsValid ? set.GetEmbed().Build() : null,
sanitizeAll: sanitizeAll,
ephemeral: ephemeral),
SmartPlainText st => smc.RespondAsync(st.Text,
default(Embed),
sanitizeAll: sanitizeAll,
ephemeral: ephemeral),
SmartEmbedTextArray arr => smc.RespondAsync(arr.Content,
embeds: arr.GetEmbedBuilders().Map(e => e.Build()),
ephemeral: ephemeral),
_ => throw new ArgumentOutOfRangeException(nameof(text))
};
public static Task EmbedAsync(
this SocketMessageComponent smc,
IEmbedBuilder? embed,
string plainText = "",
IReadOnlyCollection<IEmbedBuilder>? embeds = null,
NadekoInteraction? inter = null,
bool ephemeral = false)
=> smc.RespondAsync(plainText,
embed: embed?.Build(),
embeds: embeds?.Map(x => x.Build()),
ephemeral: ephemeral);
public static Task RespondAsync(
this SocketMessageComponent ch,
IEmbedBuilderService eb,
string text,
MsgType type,
bool ephemeral = false,
NadekoInteraction? inter = null)
{
var builder = eb.Create().WithDescription(text);
builder = (type switch
{
MsgType.Error => builder.WithErrorColor(),
MsgType.Ok => builder.WithOkColor(),
MsgType.Pending => builder.WithPendingColor(),
_ => throw new ArgumentOutOfRangeException(nameof(type))
});
return ch.EmbedAsync(builder, inter: inter, ephemeral: ephemeral);
}
// embed title and optional footer overloads
public static Task RespondErrorAsync(
this SocketMessageComponent smc,
IEmbedBuilderService eb,
string text,
bool ephemeral = false)
=> smc.RespondAsync(eb, text, MsgType.Error, ephemeral);
public static Task RespondConfirmAsync(
this SocketMessageComponent smc,
IEmbedBuilderService eb,
string text,
bool ephemeral = false)
=> smc.RespondAsync(eb, text, MsgType.Ok, ephemeral);
}

View File

@@ -0,0 +1,39 @@
using NadekoBot.Db.Models;
namespace NadekoBot.Extensions;
public static class UserExtensions
{
public static async Task<IUserMessage> EmbedAsync(this IUser user, IEmbedBuilder embed, string msg = "")
{
var ch = await user.CreateDMChannelAsync();
return await ch.EmbedAsync(embed, msg);
}
public static async Task<IUserMessage> SendAsync(this IUser user, SmartText text, bool sanitizeAll = false)
{
var ch = await user.CreateDMChannelAsync();
return await ch.SendAsync(text, sanitizeAll);
}
public static async Task<IUserMessage> SendConfirmAsync(this IUser user, IEmbedBuilderService eb, string text)
=> await user.SendMessageAsync("", embed: eb.Create().WithOkColor().WithDescription(text).Build());
public static async Task<IUserMessage> SendErrorAsync(this IUser user, IEmbedBuilderService eb, string error)
=> await user.SendMessageAsync("", embed: eb.Create().WithErrorColor().WithDescription(error).Build());
public static async Task<IUserMessage> SendPendingAsync(this IUser user, IEmbedBuilderService eb, string message)
=> await user.SendMessageAsync("", embed: eb.Create().WithPendingColor().WithDescription(message).Build());
// This method is used by everything that fetches the avatar from a user
public static Uri RealAvatarUrl(this IUser usr, ushort size = 256)
=> usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size));
// This method is only used for the xp card
public static Uri RealAvatarUrl(this DiscordUser usr)
=> usr.AvatarId is null
? new(CDN.GetDefaultUserAvatarUrl(ushort.Parse(usr.Discriminator)))
: new Uri(usr.AvatarId.StartsWith("a_", StringComparison.InvariantCulture)
? $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.gif"
: $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.png");
}