feat: upgrade to .NET 6, refactor everything

This commit is contained in:
spiral
2021-11-26 21:10:56 -05:00
parent d28e99ba43
commit 1918c56937
314 changed files with 27954 additions and 27966 deletions

View File

@@ -1,67 +1,63 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using PluralKit.Core;
using SixLabors.ImageSharp;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class AvatarUtils
{
public static class AvatarUtils
public static async Task VerifyAvatarOrThrow(HttpClient client, string url, bool isFullSizeImage = false)
{
public static async Task VerifyAvatarOrThrow(HttpClient client, string url, bool isFullSizeImage = false)
if (url.Length > Limits.MaxUriLength)
throw Errors.UrlTooLong(url);
// List of MIME types we consider acceptable
var acceptableMimeTypes = new[]
{
if (url.Length > Limits.MaxUriLength)
throw Errors.UrlTooLong(url);
"image/jpeg", "image/gif", "image/png"
// TODO: add image/webp once ImageSharp supports this
};
// List of MIME types we consider acceptable
var acceptableMimeTypes = new[]
{
"image/jpeg",
"image/gif",
"image/png"
// TODO: add image/webp once ImageSharp supports this
};
if (!PluralKit.Core.MiscUtils.TryMatchUri(url, out var uri))
throw Errors.InvalidUrl(url);
if (!PluralKit.Core.MiscUtils.TryMatchUri(url, out var uri))
throw Errors.InvalidUrl(url);
url = TryRewriteCdnUrl(url);
url = TryRewriteCdnUrl(url);
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode) // Check status code
throw Errors.AvatarServerError(response.StatusCode);
if (response.Content.Headers.ContentLength == null) // Check presence of content length
throw Errors.AvatarNotAnImage(null);
if (!acceptableMimeTypes.Contains(response.Content.Headers.ContentType.MediaType)) // Check MIME type
throw Errors.AvatarNotAnImage(response.Content.Headers.ContentType.MediaType);
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode) // Check status code
throw Errors.AvatarServerError(response.StatusCode);
if (response.Content.Headers.ContentLength == null) // Check presence of content length
throw Errors.AvatarNotAnImage(null);
if (!acceptableMimeTypes.Contains(response.Content.Headers.ContentType.MediaType)) // Check MIME type
throw Errors.AvatarNotAnImage(response.Content.Headers.ContentType.MediaType);
if (isFullSizeImage)
// no need to do size checking on banners
return;
if (isFullSizeImage)
// no need to do size checking on banners
return;
if (response.Content.Headers.ContentLength > Limits.AvatarFileSizeLimit) // Check content length
throw Errors.AvatarFileSizeLimit(response.Content.Headers.ContentLength.Value);
if (response.Content.Headers.ContentLength > Limits.AvatarFileSizeLimit) // Check content length
throw Errors.AvatarFileSizeLimit(response.Content.Headers.ContentLength.Value);
// Parse the image header in a worker
var stream = await response.Content.ReadAsStreamAsync();
var image = await Task.Run(() => Image.Identify(stream));
if (image == null) throw Errors.AvatarInvalid;
if (image.Width > Limits.AvatarDimensionLimit || image.Height > Limits.AvatarDimensionLimit) // Check image size
throw Errors.AvatarDimensionsTooLarge(image.Width, image.Height);
}
// Rewrite cdn.discordapp.com URLs to media.discordapp.net for jpg/png files
// This lets us add resizing parameters to "borrow" their media proxy server to downsize the image
// which in turn makes it more likely to be underneath the size limit!
private static readonly Regex DiscordCdnUrl = new Regex(@"^https?://(?:cdn\.discordapp\.com|media\.discordapp\.net)/attachments/(\d{17,19})/(\d{17,19})/([^/\\&\?]+)\.(png|jpg|jpeg|webp)(\?.*)?$");
private static readonly string DiscordMediaUrlReplacement = "https://media.discordapp.net/attachments/$1/$2/$3.$4?width=256&height=256";
public static string? TryRewriteCdnUrl(string? url)
{
return url == null ? null : DiscordCdnUrl.Replace(url, DiscordMediaUrlReplacement);
}
// Parse the image header in a worker
var stream = await response.Content.ReadAsStreamAsync();
var image = await Task.Run(() => Image.Identify(stream));
if (image == null) throw Errors.AvatarInvalid;
if (image.Width > Limits.AvatarDimensionLimit ||
image.Height > Limits.AvatarDimensionLimit) // Check image size
throw Errors.AvatarDimensionsTooLarge(image.Width, image.Height);
}
// Rewrite cdn.discordapp.com URLs to media.discordapp.net for jpg/png files
// This lets us add resizing parameters to "borrow" their media proxy server to downsize the image
// which in turn makes it more likely to be underneath the size limit!
private static readonly Regex DiscordCdnUrl =
new(@"^https?://(?:cdn\.discordapp\.com|media\.discordapp\.net)/attachments/(\d{17,19})/(\d{17,19})/([^/\\&\?]+)\.(png|jpg|jpeg|webp)(\?.*)?$");
private static readonly string DiscordMediaUrlReplacement =
"https://media.discordapp.net/attachments/$1/$2/$3.$4?width=256&height=256";
public static string? TryRewriteCdnUrl(string? url) =>
url == null ? null : DiscordCdnUrl.Replace(url, DiscordMediaUrlReplacement);
}

View File

@@ -1,14 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Autofac;
using Myriad.Builders;
using Myriad.Gateway;
using Myriad.Rest.Exceptions;
using Myriad.Rest.Types;
using Myriad.Rest.Types.Requests;
using Myriad.Types;
@@ -17,292 +11,307 @@ using NodaTime;
using PluralKit.Bot.Interactive;
using PluralKit.Core;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class ContextUtils
{
public static class ContextUtils
public static async Task<bool> ConfirmClear(this Context ctx, string toClear)
{
public static async Task<bool> ConfirmClear(this Context ctx, string toClear)
if (!await ctx.PromptYesNo($"{Emojis.Warn} Are you sure you want to clear {toClear}?", "Clear"))
throw Errors.GenericCancelled();
return true;
}
public static async Task<bool> PromptYesNo(this Context ctx, string msgString, string acceptButton,
User user = null, bool matchFlag = true)
{
if (matchFlag && ctx.MatchFlag("y", "yes")) return true;
var prompt = new YesNoPrompt(ctx)
{
if (!(await ctx.PromptYesNo($"{Emojis.Warn} Are you sure you want to clear {toClear}?", "Clear"))) throw Errors.GenericCancelled();
else return true;
Message = msgString,
AcceptLabel = acceptButton,
User = user?.Id ?? ctx.Author.Id
};
await prompt.Run();
return prompt.Result == true;
}
public static async Task<MessageReactionAddEvent> AwaitReaction(this Context ctx, Message message,
User user = null, Func<MessageReactionAddEvent, bool> predicate = null, Duration? timeout = null)
{
bool ReactionPredicate(MessageReactionAddEvent evt)
{
if (message.Id != evt.MessageId) return false; // Ignore reactions for different messages
if (user != null && user.Id != evt.UserId)
return false; // Ignore messages from other users if a user was defined
if (predicate != null && !predicate.Invoke(evt)) return false; // Check predicate
return true;
}
public static async Task<bool> PromptYesNo(this Context ctx, string msgString, string acceptButton, User user = null, bool matchFlag = true)
return await ctx.Services.Resolve<HandlerQueue<MessageReactionAddEvent>>()
.WaitFor(ReactionPredicate, timeout);
}
public static async Task<bool> ConfirmWithReply(this Context ctx, string expectedReply)
{
bool Predicate(MessageCreateEvent e) =>
e.Author.Id == ctx.Author.Id && e.ChannelId == ctx.Channel.Id;
var msg = await ctx.Services.Resolve<HandlerQueue<MessageCreateEvent>>()
.WaitFor(Predicate, Duration.FromMinutes(1));
return string.Equals(msg.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase);
}
public static async Task Paginate<T>(this Context ctx, IAsyncEnumerable<T> items, int totalCount,
int itemsPerPage, string title, string color, Func<EmbedBuilder, IEnumerable<T>, Task> renderer)
{
// TODO: make this generic enough we can use it in Choose<T> below
var buffer = new List<T>();
await using var enumerator = items.GetAsyncEnumerator();
var pageCount = (int)Math.Ceiling(totalCount / (double)itemsPerPage);
async Task<Embed> MakeEmbedForPage(int page)
{
if (matchFlag && ctx.MatchFlag("y", "yes")) return true;
var bufferedItemsNeeded = (page + 1) * itemsPerPage;
while (buffer.Count < bufferedItemsNeeded && await enumerator.MoveNextAsync())
buffer.Add(enumerator.Current);
var prompt = new YesNoPrompt(ctx)
{
Message = msgString,
AcceptLabel = acceptButton,
User = user?.Id ?? ctx.Author.Id,
};
await prompt.Run();
return prompt.Result == true;
var eb = new EmbedBuilder();
eb.Title(pageCount > 1 ? $"[{page + 1}/{pageCount}] {title}" : title);
if (color != null)
eb.Color(color.ToDiscordColor());
await renderer(eb, buffer.Skip(page * itemsPerPage).Take(itemsPerPage));
return eb.Build();
}
public static async Task<MessageReactionAddEvent> AwaitReaction(this Context ctx, Message message, User user = null, Func<MessageReactionAddEvent, bool> predicate = null, Duration? timeout = null)
{
bool ReactionPredicate(MessageReactionAddEvent evt)
{
if (message.Id != evt.MessageId) return false; // Ignore reactions for different messages
if (user != null && user.Id != evt.UserId) return false; // Ignore messages from other users if a user was defined
if (predicate != null && !predicate.Invoke(evt)) return false; // Check predicate
return true;
}
return await ctx.Services.Resolve<HandlerQueue<MessageReactionAddEvent>>().WaitFor(ReactionPredicate, timeout);
}
public static async Task<bool> ConfirmWithReply(this Context ctx, string expectedReply)
async Task<int> PromptPageNumber()
{
bool Predicate(MessageCreateEvent e) =>
e.Author.Id == ctx.Author.Id && e.ChannelId == ctx.Channel.Id;
var msg = await ctx.Services.Resolve<HandlerQueue<MessageCreateEvent>>()
.WaitFor(Predicate, Duration.FromMinutes(1));
.WaitFor(Predicate, Duration.FromMinutes(0.5));
return string.Equals(msg.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase);
int.TryParse(msg.Content, out var num);
return num;
}
public static async Task Paginate<T>(this Context ctx, IAsyncEnumerable<T> items, int totalCount, int itemsPerPage, string title, string color, Func<EmbedBuilder, IEnumerable<T>, Task> renderer)
try
{
// TODO: make this generic enough we can use it in Choose<T> below
var msg = await ctx.Reply(embed: await MakeEmbedForPage(0));
var buffer = new List<T>();
await using var enumerator = items.GetAsyncEnumerator();
// If we only have one (or no) page, don't bother with the reaction/pagination logic, lol
if (pageCount <= 1) return;
var pageCount = (int)Math.Ceiling(totalCount / (double)itemsPerPage);
async Task<Embed> MakeEmbedForPage(int page)
{
var bufferedItemsNeeded = (page + 1) * itemsPerPage;
while (buffer.Count < bufferedItemsNeeded && await enumerator.MoveNextAsync())
buffer.Add(enumerator.Current);
string[] botEmojis = { "\u23EA", "\u2B05", "\u27A1", "\u23E9", "\uD83D\uDD22", Emojis.Error };
var eb = new EmbedBuilder();
eb.Title(pageCount > 1 ? $"[{page + 1}/{pageCount}] {title}" : title);
if (color != null)
eb.Color(color.ToDiscordColor());
await renderer(eb, buffer.Skip(page * itemsPerPage).Take(itemsPerPage));
return eb.Build();
}
async Task<int> PromptPageNumber()
{
bool Predicate(MessageCreateEvent e) =>
e.Author.Id == ctx.Author.Id && e.ChannelId == ctx.Channel.Id;
var msg = await ctx.Services.Resolve<HandlerQueue<MessageCreateEvent>>()
.WaitFor(Predicate, Duration.FromMinutes(0.5));
int.TryParse(msg.Content, out int num);
return num;
}
var _ = ctx.Rest.CreateReactionsBulk(msg, botEmojis); // Again, "fork"
try
{
var msg = await ctx.Reply(embed: await MakeEmbedForPage(0));
if (pageCount <= 1) return; // If we only have one (or no) page, don't bother with the reaction/pagination logic, lol
string[] botEmojis = { "\u23EA", "\u2B05", "\u27A1", "\u23E9", "\uD83D\uDD22", Emojis.Error };
var _ = ctx.Rest.CreateReactionsBulk(msg, botEmojis); // Again, "fork"
try
{
var currentPage = 0;
while (true)
{
var reaction = await ctx.AwaitReaction(msg, ctx.Author, timeout: Duration.FromMinutes(5));
// Increment/decrement page counter based on which reaction was clicked
if (reaction.Emoji.Name == "\u23EA") currentPage = 0; // <<
else if (reaction.Emoji.Name == "\u2B05") currentPage = (currentPage - 1) % pageCount; // <
else if (reaction.Emoji.Name == "\u27A1") currentPage = (currentPage + 1) % pageCount; // >
else if (reaction.Emoji.Name == "\u23E9") currentPage = pageCount - 1; // >>
else if (reaction.Emoji.Name == Emojis.Error) break; // X
else if (reaction.Emoji.Name == "\u0031\uFE0F\u20E3") currentPage = 0;
else if (reaction.Emoji.Name == "\u0032\uFE0F\u20E3") currentPage = 1;
else if (reaction.Emoji.Name == "\u0033\uFE0F\u20E3") currentPage = 2;
else if (reaction.Emoji.Name == "\u0034\uFE0F\u20E3" && pageCount >= 3) currentPage = 3;
else if (reaction.Emoji.Name == "\u0035\uFE0F\u20E3" && pageCount >= 4) currentPage = 4;
else if (reaction.Emoji.Name == "\u0036\uFE0F\u20E3" && pageCount >= 5) currentPage = 5;
else if (reaction.Emoji.Name == "\u0037\uFE0F\u20E3" && pageCount >= 6) currentPage = 6;
else if (reaction.Emoji.Name == "\u0038\uFE0F\u20E3" && pageCount >= 7) currentPage = 7;
else if (reaction.Emoji.Name == "\u0039\uFE0F\u20E3" && pageCount >= 8) currentPage = 8;
else if (reaction.Emoji.Name == "\U0001f51f" && pageCount >= 9) currentPage = 9;
else if (reaction.Emoji.Name == "\uD83D\uDD22")
{
try
{
await ctx.Reply("What page would you like to go to?");
var repliedNum = await PromptPageNumber();
if (repliedNum < 1)
{
await ctx.Reply($"{Emojis.Error} Operation canceled (invalid number).");
continue;
}
if (repliedNum > pageCount)
{
await ctx.Reply($"{Emojis.Error} That page number is too high (page count is {pageCount}).");
continue;
}
currentPage = repliedNum - 1;
}
catch (TimeoutException)
{
await ctx.Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
continue;
}
}
// C#'s % operator is dumb and wrong, so we fix negative numbers
if (currentPage < 0) currentPage += pageCount;
// If we can, remove the user's reaction (so they can press again quickly)
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
await ctx.Rest.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, reaction.UserId);
// Edit the embed with the new page
var embed = await MakeEmbedForPage(currentPage);
await ctx.Rest.EditMessage(msg.ChannelId, msg.Id, new MessageEditRequest { Embed = embed });
}
}
catch (TimeoutException)
{
// "escape hatch", clean up as if we hit X
}
// todo: re-check
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
await ctx.Rest.DeleteAllReactions(msg.ChannelId, msg.Id);
}
// If we get a "NotFound" error, the message has been deleted and thus not our problem
catch (NotFoundException) { }
// If we get an "Unauthorized" error, we don't have permissions to remove our reaction
// which means we probably didn't add it in the first place, or permissions changed since then
// either way, nothing to do here
catch (ForbiddenException) { }
}
public static async Task<T> Choose<T>(this Context ctx, string description, IList<T> items, Func<T, string> display = null)
{
// Generate a list of :regional_indicator_?: emoji surrogate pairs (starting at codepoint 0x1F1E6)
// We just do 7 (ABCDEFG), this amount is arbitrary (although sending a lot of emojis takes a while)
var pageSize = 7;
var indicators = new string[pageSize];
for (var i = 0; i < pageSize; i++) indicators[i] = char.ConvertFromUtf32(0x1F1E6 + i);
// Default to x.ToString()
if (display == null) display = x => x.ToString();
string MakeOptionList(int page)
{
var makeOptionList = string.Join("\n", items
.Skip(page * pageSize)
.Take(pageSize)
.Select((x, i) => $"{indicators[i]} {display(x)}"));
return makeOptionList;
}
// If we have more items than the page size, we paginate as appropriate
if (items.Count > pageSize)
{
var currPage = 0;
var pageCount = (items.Count - 1) / pageSize + 1;
// Send the original message
var msg = await ctx.Reply($"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}");
// Add back/forward reactions and the actual indicator emojis
async Task AddEmojis()
{
await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new() { Name = "\u2B05" });
await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new() { Name = "\u27A1" });
for (int i = 0; i < items.Count; i++)
await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new() { Name = indicators[i] });
}
var _ = AddEmojis(); // Not concerned about awaiting
var currentPage = 0;
while (true)
{
// Wait for a reaction
var reaction = await ctx.AwaitReaction(msg, ctx.Author);
var reaction = await ctx.AwaitReaction(msg, ctx.Author, timeout: Duration.FromMinutes(5));
// If it's a movement reaction, inc/dec the page index
if (reaction.Emoji.Name == "\u2B05") currPage -= 1; // <
if (reaction.Emoji.Name == "\u27A1") currPage += 1; // >
if (currPage < 0) currPage += pageCount;
if (currPage >= pageCount) currPage -= pageCount;
// Increment/decrement page counter based on which reaction was clicked
if (reaction.Emoji.Name == "\u23EA") currentPage = 0; // <<
else if (reaction.Emoji.Name == "\u2B05") currentPage = (currentPage - 1) % pageCount; // <
else if (reaction.Emoji.Name == "\u27A1") currentPage = (currentPage + 1) % pageCount; // >
else if (reaction.Emoji.Name == "\u23E9") currentPage = pageCount - 1; // >>
else if (reaction.Emoji.Name == Emojis.Error) break; // X
// If it's an indicator emoji, return the relevant item
if (indicators.Contains(reaction.Emoji.Name))
{
var idx = Array.IndexOf(indicators, reaction.Emoji.Name) + pageSize * currPage;
// only if it's in bounds, though
// eg. 8 items, we're on page 2, and I hit D (3 + 1*7 = index 10 on an 8-long list) = boom
if (idx < items.Count) return items[idx];
}
else if (reaction.Emoji.Name == "\u0031\uFE0F\u20E3") currentPage = 0;
else if (reaction.Emoji.Name == "\u0032\uFE0F\u20E3") currentPage = 1;
else if (reaction.Emoji.Name == "\u0033\uFE0F\u20E3") currentPage = 2;
else if (reaction.Emoji.Name == "\u0034\uFE0F\u20E3" && pageCount >= 3) currentPage = 3;
else if (reaction.Emoji.Name == "\u0035\uFE0F\u20E3" && pageCount >= 4) currentPage = 4;
else if (reaction.Emoji.Name == "\u0036\uFE0F\u20E3" && pageCount >= 5) currentPage = 5;
else if (reaction.Emoji.Name == "\u0037\uFE0F\u20E3" && pageCount >= 6) currentPage = 6;
else if (reaction.Emoji.Name == "\u0038\uFE0F\u20E3" && pageCount >= 7) currentPage = 7;
else if (reaction.Emoji.Name == "\u0039\uFE0F\u20E3" && pageCount >= 8) currentPage = 8;
else if (reaction.Emoji.Name == "\U0001f51f" && pageCount >= 9) currentPage = 9;
var __ = ctx.Rest.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, ctx.Author.Id);
await ctx.Rest.EditMessage(msg.ChannelId, msg.Id,
new()
else if (reaction.Emoji.Name == "\uD83D\uDD22")
try
{
Content =
$"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}"
});
await ctx.Reply("What page would you like to go to?");
var repliedNum = await PromptPageNumber();
if (repliedNum < 1)
{
await ctx.Reply($"{Emojis.Error} Operation canceled (invalid number).");
continue;
}
if (repliedNum > pageCount)
{
await ctx.Reply(
$"{Emojis.Error} That page number is too high (page count is {pageCount}).");
continue;
}
currentPage = repliedNum - 1;
}
catch (TimeoutException)
{
await ctx.Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
continue;
}
// C#'s % operator is dumb and wrong, so we fix negative numbers
if (currentPage < 0) currentPage += pageCount;
// If we can, remove the user's reaction (so they can press again quickly)
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
await ctx.Rest.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, reaction.UserId);
// Edit the embed with the new page
var embed = await MakeEmbedForPage(currentPage);
await ctx.Rest.EditMessage(msg.ChannelId, msg.Id, new MessageEditRequest { Embed = embed });
}
}
else
catch (TimeoutException)
{
var msg = await ctx.Reply($"{description}\n{MakeOptionList(0)}");
// "escape hatch", clean up as if we hit X
}
// Add the relevant reactions (we don't care too much about awaiting)
async Task AddEmojis()
// todo: re-check
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
await ctx.Rest.DeleteAllReactions(msg.ChannelId, msg.Id);
}
// If we get a "NotFound" error, the message has been deleted and thus not our problem
catch (NotFoundException) { }
// If we get an "Unauthorized" error, we don't have permissions to remove our reaction
// which means we probably didn't add it in the first place, or permissions changed since then
// either way, nothing to do here
catch (ForbiddenException) { }
}
public static async Task<T> Choose<T>(this Context ctx, string description, IList<T> items,
Func<T, string> display = null)
{
// Generate a list of :regional_indicator_?: emoji surrogate pairs (starting at codepoint 0x1F1E6)
// We just do 7 (ABCDEFG), this amount is arbitrary (although sending a lot of emojis takes a while)
var pageSize = 7;
var indicators = new string[pageSize];
for (var i = 0; i < pageSize; i++) indicators[i] = char.ConvertFromUtf32(0x1F1E6 + i);
// Default to x.ToString()
if (display == null) display = x => x.ToString();
string MakeOptionList(int page)
{
var makeOptionList = string.Join("\n", items
.Skip(page * pageSize)
.Take(pageSize)
.Select((x, i) => $"{indicators[i]} {display(x)}"));
return makeOptionList;
}
// If we have more items than the page size, we paginate as appropriate
if (items.Count > pageSize)
{
var currPage = 0;
var pageCount = (items.Count - 1) / pageSize + 1;
// Send the original message
var msg = await ctx.Reply(
$"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}");
// Add back/forward reactions and the actual indicator emojis
async Task AddEmojis()
{
await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new Emoji { Name = "\u2B05" });
await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new Emoji { Name = "\u27A1" });
for (var i = 0; i < items.Count; i++)
await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new Emoji { Name = indicators[i] });
}
var _ = AddEmojis(); // Not concerned about awaiting
while (true)
{
// Wait for a reaction
var reaction = await ctx.AwaitReaction(msg, ctx.Author);
// If it's a movement reaction, inc/dec the page index
if (reaction.Emoji.Name == "\u2B05") currPage -= 1; // <
if (reaction.Emoji.Name == "\u27A1") currPage += 1; // >
if (currPage < 0) currPage += pageCount;
if (currPage >= pageCount) currPage -= pageCount;
// If it's an indicator emoji, return the relevant item
if (indicators.Contains(reaction.Emoji.Name))
{
for (int i = 0; i < items.Count; i++)
await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new() { Name = indicators[i] });
var idx = Array.IndexOf(indicators, reaction.Emoji.Name) + pageSize * currPage;
// only if it's in bounds, though
// eg. 8 items, we're on page 2, and I hit D (3 + 1*7 = index 10 on an 8-long list) = boom
if (idx < items.Count) return items[idx];
}
var _ = AddEmojis();
// Then wait for a reaction and return whichever one we found
var reaction = await ctx.AwaitReaction(msg, ctx.Author, rx => indicators.Contains(rx.Emoji.Name));
return items[Array.IndexOf(indicators, reaction.Emoji.Name)];
var __ = ctx.Rest.DeleteUserReaction(msg.ChannelId, msg.Id, reaction.Emoji, ctx.Author.Id);
await ctx.Rest.EditMessage(msg.ChannelId, msg.Id,
new MessageEditRequest
{
Content =
$"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}"
});
}
}
public static async Task BusyIndicator(this Context ctx, Func<Task> f, string emoji = "\u23f3" /* hourglass */)
else
{
await ctx.BusyIndicator<object>(async () =>
var msg = await ctx.Reply($"{description}\n{MakeOptionList(0)}");
// Add the relevant reactions (we don't care too much about awaiting)
async Task AddEmojis()
{
await f();
return null;
}, emoji);
for (var i = 0; i < items.Count; i++)
await ctx.Rest.CreateReaction(msg.ChannelId, msg.Id, new Emoji { Name = indicators[i] });
}
var _ = AddEmojis();
// Then wait for a reaction and return whichever one we found
var reaction = await ctx.AwaitReaction(msg, ctx.Author, rx => indicators.Contains(rx.Emoji.Name));
return items[Array.IndexOf(indicators, reaction.Emoji.Name)];
}
}
public static async Task<T> BusyIndicator<T>(this Context ctx, Func<Task<T>> f, string emoji = "\u23f3" /* hourglass */)
public static async Task BusyIndicator(this Context ctx, Func<Task> f, string emoji = "\u23f3" /* hourglass */)
{
await ctx.BusyIndicator<object>(async () =>
{
var task = f();
await f();
return null;
}, emoji);
}
// If we don't have permission to add reactions, don't bother, and just await the task normally.
if (!await DiscordUtils.HasReactionPermissions(ctx)) return await task;
public static async Task<T> BusyIndicator<T>(this Context ctx, Func<Task<T>> f,
string emoji = "\u23f3" /* hourglass */)
{
var task = f();
try
{
await Task.WhenAll(ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new() { Name = emoji }), task);
return await task;
}
finally
{
var _ = ctx.Rest.DeleteOwnReaction(ctx.Message.ChannelId, ctx.Message.Id, new() { Name = emoji });
}
// If we don't have permission to add reactions, don't bother, and just await the task normally.
if (!await DiscordUtils.HasReactionPermissions(ctx)) return await task;
try
{
await Task.WhenAll(
ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new Emoji { Name = emoji }),
task
);
return await task;
}
finally
{
var _ = ctx.Rest.DeleteOwnReaction(ctx.Message.ChannelId, ctx.Message.Id, new Emoji { Name = emoji });
}
}
}

View File

@@ -1,9 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Myriad.Builders;
using Myriad.Extensions;
@@ -17,198 +13,192 @@ using NodaTime;
using PluralKit.Core;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class DiscordUtils
{
public static class DiscordUtils
public const uint Blue = 0x1f99d8;
public const uint Green = 0x00cc78;
public const uint Red = 0xef4b3d;
public const uint Gray = 0x979c9f;
private static readonly Regex USER_MENTION = new("<@!?(\\d{17,19})>");
private static readonly Regex ROLE_MENTION = new("<@&(\\d{17,19})>");
private static readonly Regex EVERYONE_HERE_MENTION = new("@(everyone|here)");
// Discord uses Khan Academy's simple-markdown library for parsing Markdown,
// which uses the following regex for link detection:
// ^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])
// Source: https://raw.githubusercontent.com/DJScias/Discord-Datamining/master/2020/2020-07-10/47efb8681861cb7c5ffa.js @ line 20633
// corresponding to: https://github.com/Khan/simple-markdown/blob/master/src/index.js#L1489
// I added <? and >? at the start/end; they need to be handled specially later...
private static readonly Regex UNBROKEN_LINK_REGEX = new("<?(https?:\\/\\/[^\\s<]+[^<.,:;\"')\\]\\s])>?");
public static string NameAndMention(this User user) =>
$"{user.Username}#{user.Discriminator} ({user.Mention()})";
public static Instant SnowflakeToInstant(ulong snowflake) =>
Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22);
public static ulong InstantToSnowflake(Instant time) =>
(ulong)(time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds << 22;
public static async Task CreateReactionsBulk(this DiscordApiClient rest, Message msg, string[] reactions)
{
public const uint Blue = 0x1f99d8;
public const uint Green = 0x00cc78;
public const uint Red = 0xef4b3d;
public const uint Gray = 0x979c9f;
foreach (var reaction in reactions)
await rest.CreateReaction(msg.ChannelId, msg.Id, new Emoji { Name = reaction });
}
private static readonly Regex USER_MENTION = new Regex("<@!?(\\d{17,19})>");
private static readonly Regex ROLE_MENTION = new Regex("<@&(\\d{17,19})>");
private static readonly Regex EVERYONE_HERE_MENTION = new Regex("@(everyone|here)");
// Discord uses Khan Academy's simple-markdown library for parsing Markdown,
// which uses the following regex for link detection:
// ^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])
// Source: https://raw.githubusercontent.com/DJScias/Discord-Datamining/master/2020/2020-07-10/47efb8681861cb7c5ffa.js @ line 20633
// corresponding to: https://github.com/Khan/simple-markdown/blob/master/src/index.js#L1489
// I added <? and >? at the start/end; they need to be handled specially later...
private static readonly Regex UNBROKEN_LINK_REGEX = new Regex("<?(https?:\\/\\/[^\\s<]+[^<.,:;\"')\\]\\s])>?");
public static string NameAndMention(this User user)
public static async Task<Message?> GetMessageOrNull(this DiscordApiClient rest, ulong channelId,
ulong messageId)
{
try
{
return $"{user.Username}#{user.Discriminator} ({user.Mention()})";
return await rest.GetMessage(channelId, messageId);
}
public static Instant SnowflakeToInstant(ulong snowflake) =>
Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22);
public static ulong InstantToSnowflake(Instant time) =>
(ulong)(time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds << 22;
public static async Task CreateReactionsBulk(this DiscordApiClient rest, Message msg, string[] reactions)
catch (ForbiddenException)
{
foreach (var reaction in reactions)
{
await rest.CreateReaction(msg.ChannelId, msg.Id, new() { Name = reaction });
}
// no permission, couldn't fetch, oh well
return null;
}
}
public static async Task<Message?> GetMessageOrNull(this DiscordApiClient rest, ulong channelId, ulong messageId)
public static uint? ToDiscordColor(this string color)
{
if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt))
return colorInt;
throw new ArgumentException($"Invalid color string '{color}'.");
}
public static bool HasMentionPrefix(string content, ref int argPos, out ulong mentionId)
{
mentionId = 0;
// Roughly ported from Discord.Commands.MessageExtensions.HasMentionPrefix
if (string.IsNullOrEmpty(content) || content.Length <= 3 || content[0] != '<' || content[1] != '@')
return false;
var num = content.IndexOf('>');
if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' ||
!TryParseMention(content.Substring(0, num + 1), out mentionId))
return false;
argPos = num + 2;
return true;
}
public static bool TryParseMention(this string potentialMention, out ulong id)
{
if (ulong.TryParse(potentialMention, out id)) return true;
var match = USER_MENTION.Match(potentialMention);
if (match.Success && match.Index == 0 && match.Length == potentialMention.Length)
{
try
{
return await rest.GetMessage(channelId, messageId);
}
catch (ForbiddenException)
{
// no permission, couldn't fetch, oh well
return null;
}
}
public static uint? ToDiscordColor(this string color)
{
if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt))
return colorInt;
throw new ArgumentException($"Invalid color string '{color}'.");
}
public static bool HasMentionPrefix(string content, ref int argPos, out ulong mentionId)
{
mentionId = 0;
// Roughly ported from Discord.Commands.MessageExtensions.HasMentionPrefix
if (string.IsNullOrEmpty(content) || content.Length <= 3 || (content[0] != '<' || content[1] != '@'))
return false;
int num = content.IndexOf('>');
if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' ||
!TryParseMention(content.Substring(0, num + 1), out mentionId))
return false;
argPos = num + 2;
id = ulong.Parse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture);
return true;
}
public static bool TryParseMention(this string potentialMention, out ulong id)
{
if (ulong.TryParse(potentialMention, out id)) return true;
var match = USER_MENTION.Match(potentialMention);
if (match.Success && match.Index == 0 && match.Length == potentialMention.Length)
{
id = ulong.Parse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture);
return true;
}
return false;
}
public static AllowedMentions ParseMentions(this string input)
{
var users = USER_MENTION.Matches(input).Select(x => ulong.Parse(x.Groups[1].Value));
var roles = ROLE_MENTION.Matches(input).Select(x => ulong.Parse(x.Groups[1].Value));
var everyone = EVERYONE_HERE_MENTION.IsMatch(input);
return new AllowedMentions
{
Users = users.Distinct().ToArray(),
Roles = roles.Distinct().ToArray(),
Parse = everyone ? new[] { AllowedMentions.ParseType.Everyone } : null
};
}
public static AllowedMentions RemoveUnmentionableRoles(this AllowedMentions mentions, Guild guild)
{
return mentions with
{
Roles = mentions.Roles
?.Where(id => guild.Roles.FirstOrDefault(r => r.Id == id)?.Mentionable == true)
.ToArray()
};
}
public static string EscapeMarkdown(this string input)
{
Regex pattern = new Regex(@"[*_~>`(||)\\]", RegexOptions.Multiline);
if (input != null) return pattern.Replace(input, @"\$&");
else return input;
}
public static string EscapeBacktickPair(this string input)
{
if (input == null)
return null;
// Break all pairs of backticks by placing a ZWNBSP (U+FEFF) between them.
// Run twice to catch any pairs that are created from the first pass
var escaped = input
.Replace("``", "`\ufeff`")
.Replace("``", "`\ufeff`");
// Escape the start/end of the string if necessary to better "connect" with other things
if (escaped.StartsWith("`")) escaped = "\ufeff" + escaped;
if (escaped.EndsWith("`")) escaped = escaped + "\ufeff";
return escaped;
}
public static string AsCode(this string input)
{
// Inline code blocks started with two backticks need to end with two backticks
// So, surrounding with two backticks, then escaping all backtick pairs makes it impossible(!) to "break out"
return $"``{EscapeBacktickPair(input)}``";
}
public static EmbedBuilder WithSimpleLineContent(this EmbedBuilder eb, IEnumerable<string> lines)
{
static int CharacterLimit(int pageNumber) =>
// First chunk goes in description (2048 chars), rest go in embed values (1000 chars)
pageNumber == 0 ? 2048 : 1000;
var linesWithEnding = lines.Select(l => $"{l}\n");
var pages = StringUtils.JoinPages(linesWithEnding, CharacterLimit);
// Add the first page to the embed description
if (pages.Count > 0)
eb.Description(pages[0]);
// Add the rest to blank-named (\u200B) fields
for (var i = 1; i < pages.Count; i++)
eb.Field(new("\u200B", pages[i]));
return eb;
}
public static string BreakLinkEmbeds(this string str) =>
// Encases URLs in <brackets>
UNBROKEN_LINK_REGEX.Replace(str, match =>
{
// Don't break already-broken links
// The regex will include the brackets in the match, so we can check for their presence here
if (match.Value.StartsWith("<") && match.Value.EndsWith(">"))
return match.Value;
return $"<{match.Value}>";
});
public static string EventType(this IGatewayEvent evt) =>
evt.GetType().Name.Replace("Event", "");
public static async Task<bool> HasReactionPermissions(Context ctx)
{
var neededPermissions = PermissionSet.AddReactions | PermissionSet.ReadMessageHistory;
return ((await ctx.BotPermissions & neededPermissions) == neededPermissions);
}
public static bool IsValidGuildChannel(Channel channel) =>
channel.Type is
Channel.ChannelType.GuildText or
Channel.ChannelType.GuildVoice or
Channel.ChannelType.GuildNews or
Channel.ChannelType.GuildPublicThread or
Channel.ChannelType.GuildPrivateThread or
Channel.ChannelType.GuildNewsThread;
return false;
}
public static AllowedMentions ParseMentions(this string input)
{
var users = USER_MENTION.Matches(input).Select(x => ulong.Parse(x.Groups[1].Value));
var roles = ROLE_MENTION.Matches(input).Select(x => ulong.Parse(x.Groups[1].Value));
var everyone = EVERYONE_HERE_MENTION.IsMatch(input);
return new AllowedMentions
{
Users = users.Distinct().ToArray(),
Roles = roles.Distinct().ToArray(),
Parse = everyone ? new[] { AllowedMentions.ParseType.Everyone } : null
};
}
public static AllowedMentions RemoveUnmentionableRoles(this AllowedMentions mentions, Guild guild)
{
return mentions with
{
Roles = mentions.Roles
?.Where(id => guild.Roles.FirstOrDefault(r => r.Id == id)?.Mentionable == true)
.ToArray()
};
}
public static string EscapeMarkdown(this string input)
{
var pattern = new Regex(@"[*_~>`(||)\\]", RegexOptions.Multiline);
if (input != null) return pattern.Replace(input, @"\$&");
return input;
}
public static string EscapeBacktickPair(this string input)
{
if (input == null)
return null;
// Break all pairs of backticks by placing a ZWNBSP (U+FEFF) between them.
// Run twice to catch any pairs that are created from the first pass
var escaped = input
.Replace("``", "`\ufeff`")
.Replace("``", "`\ufeff`");
// Escape the start/end of the string if necessary to better "connect" with other things
if (escaped.StartsWith("`")) escaped = "\ufeff" + escaped;
if (escaped.EndsWith("`")) escaped = escaped + "\ufeff";
return escaped;
}
public static string AsCode(this string input) =>
// Inline code blocks started with two backticks need to end with two backticks
// So, surrounding with two backticks, then escaping all backtick pairs makes it impossible(!) to "break out"
$"``{EscapeBacktickPair(input)}``";
public static EmbedBuilder WithSimpleLineContent(this EmbedBuilder eb, IEnumerable<string> lines)
{
static int CharacterLimit(int pageNumber) =>
// First chunk goes in description (2048 chars), rest go in embed values (1000 chars)
pageNumber == 0 ? 2048 : 1000;
var linesWithEnding = lines.Select(l => $"{l}\n");
var pages = StringUtils.JoinPages(linesWithEnding, CharacterLimit);
// Add the first page to the embed description
if (pages.Count > 0)
eb.Description(pages[0]);
// Add the rest to blank-named (\u200B) fields
for (var i = 1; i < pages.Count; i++)
eb.Field(new Embed.Field("\u200B", pages[i]));
return eb;
}
public static string BreakLinkEmbeds(this string str) =>
// Encases URLs in <brackets>
UNBROKEN_LINK_REGEX.Replace(str, match =>
{
// Don't break already-broken links
// The regex will include the brackets in the match, so we can check for their presence here
if (match.Value.StartsWith("<") && match.Value.EndsWith(">"))
return match.Value;
return $"<{match.Value}>";
});
public static string EventType(this IGatewayEvent evt) =>
evt.GetType().Name.Replace("Event", "");
public static async Task<bool> HasReactionPermissions(Context ctx)
{
var neededPermissions = PermissionSet.AddReactions | PermissionSet.ReadMessageHistory;
return (await ctx.BotPermissions & neededPermissions) == neededPermissions;
}
public static bool IsValidGuildChannel(Channel channel) =>
channel.Type is
Channel.ChannelType.GuildText or
Channel.ChannelType.GuildVoice or
Channel.ChannelType.GuildNews or
Channel.ChannelType.GuildPublicThread or
Channel.ChannelType.GuildPrivateThread or
Channel.ChannelType.GuildNewsThread;
}

View File

@@ -1,78 +1,78 @@
using System;
using PluralKit.Core;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class GroupMemberUtils
{
public static class GroupMemberUtils
public static string GenerateResponse(Groups.AddRemoveOperation action, int memberCount, int groupCount,
int actionedOn, int notActionedOn)
{
public static string GenerateResponse(Groups.AddRemoveOperation action, int memberCount, int groupCount, int actionedOn, int notActionedOn)
var op = action;
var actionStr = action == Groups.AddRemoveOperation.Add ? "added to" : "removed from";
var containStr = action == Groups.AddRemoveOperation.Add ? "in" : "not in";
var emojiStr = actionedOn > 0 ? Emojis.Success : Emojis.Error;
var memberPlural = memberCount > 1;
var groupPlural = groupCount > 1;
// sanity checking: we can't add multiple groups to multiple members (at least for now)
if (memberPlural && groupPlural)
throw new ArgumentOutOfRangeException();
// sanity checking: we can't act/not act on a different number of entities than we have
if (memberPlural && actionedOn + notActionedOn != memberCount)
throw new ArgumentOutOfRangeException();
if (groupPlural && actionedOn + notActionedOn != groupCount)
throw new ArgumentOutOfRangeException();
// name generators
string MemberString(int count, bool capitalize = false)
=> capitalize
? count == 1 ? "Member" : "Members"
: count == 1
? "member"
: "members";
string GroupString(int count)
=> count == 1 ? "group" : "groups";
// string generators
string ResponseString()
{
var op = action;
if (actionedOn > 0 && notActionedOn > 0 && memberPlural)
return $"{actionedOn} {MemberString(actionedOn)} {actionStr} {GroupString(groupCount)}";
if (actionedOn > 0 && notActionedOn > 0 && groupPlural)
return $"{MemberString(memberCount, true)} {actionStr} {actionedOn} {GroupString(actionedOn)}";
if (notActionedOn == 0)
return $"{MemberString(memberCount, true)} {actionStr} {GroupString(groupCount)}";
if (actionedOn == 0)
return $"{MemberString(memberCount, true)} not {actionStr} {GroupString(groupCount)}";
var actionStr = action == Groups.AddRemoveOperation.Add ? "added to" : "removed from";
var containStr = action == Groups.AddRemoveOperation.Add ? "in" : "not in";
var emojiStr = actionedOn > 0 ? Emojis.Success : Emojis.Error;
var memberPlural = memberCount > 1;
var groupPlural = groupCount > 1;
// sanity checking: we can't add multiple groups to multiple members (at least for now)
if (memberPlural && groupPlural)
throw new ArgumentOutOfRangeException();
// sanity checking: we can't act/not act on a different number of entities than we have
if (memberPlural && (actionedOn + notActionedOn) != memberCount)
throw new ArgumentOutOfRangeException();
if (groupPlural && (actionedOn + notActionedOn) != groupCount)
throw new ArgumentOutOfRangeException();
// name generators
string MemberString(int count, bool capitalize = false)
=> capitalize
? (count == 1 ? "Member" : "Members")
: (count == 1 ? "member" : "members");
string GroupString(int count)
=> count == 1 ? "group" : "groups";
// string generators
string ResponseString()
{
if (actionedOn > 0 && notActionedOn > 0 && memberPlural)
return $"{actionedOn} {MemberString(actionedOn)} {actionStr} {GroupString(groupCount)}";
if (actionedOn > 0 && notActionedOn > 0 && groupPlural)
return $"{MemberString(memberCount, capitalize: true)} {actionStr} {actionedOn} {GroupString(actionedOn)}";
if (notActionedOn == 0)
return $"{MemberString(memberCount, capitalize: true)} {actionStr} {GroupString(groupCount)}";
if (actionedOn == 0)
return $"{MemberString(memberCount, capitalize: true)} not {actionStr} {GroupString(groupCount)}";
throw new ArgumentOutOfRangeException();
}
string InfoMessage()
{
if (notActionedOn == 0) return $"";
var msg = "";
if (actionedOn > 0 && memberPlural)
msg += $"{notActionedOn} {MemberString(notActionedOn)}";
else
msg += $"{MemberString(memberCount)}";
msg += $" already {containStr}";
if (actionedOn > 0 && groupPlural)
msg += $" {notActionedOn} {GroupString(notActionedOn)}";
else
msg += $" {GroupString(groupCount)}";
return $" ({msg})";
}
return $"{emojiStr} {ResponseString()}{InfoMessage()}.";
throw new ArgumentOutOfRangeException();
}
string InfoMessage()
{
if (notActionedOn == 0) return "";
var msg = "";
if (actionedOn > 0 && memberPlural)
msg += $"{notActionedOn} {MemberString(notActionedOn)}";
else
msg += $"{MemberString(memberCount)}";
msg += $" already {containStr}";
if (actionedOn > 0 && groupPlural)
msg += $" {notActionedOn} {GroupString(notActionedOn)}";
else
msg += $" {GroupString(groupCount)}";
return $" ({msg})";
}
return $"{emojiStr} {ResponseString()}{InfoMessage()}.";
}
}

View File

@@ -1,63 +1,56 @@
using System;
using System.Threading.Tasks;
using Autofac;
using Myriad.Gateway;
using Myriad.Rest;
using Myriad.Types;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public class InteractionContext
{
public class InteractionContext
private readonly ILifetimeScope _services;
public InteractionContext(InteractionCreateEvent evt, ILifetimeScope services)
{
private readonly InteractionCreateEvent _evt;
private readonly ILifetimeScope _services;
Event = evt;
_services = services;
}
public InteractionContext(InteractionCreateEvent evt, ILifetimeScope services)
{
_evt = evt;
_services = services;
}
public InteractionCreateEvent Event { get; }
public ulong ChannelId => _evt.ChannelId;
public ulong? MessageId => _evt.Message?.Id;
public GuildMember? Member => _evt.Member;
public User User => _evt.Member?.User ?? _evt.User;
public string Token => _evt.Token;
public string? CustomId => _evt.Data?.CustomId;
public InteractionCreateEvent Event => _evt;
public ulong ChannelId => Event.ChannelId;
public ulong? MessageId => Event.Message?.Id;
public GuildMember? Member => Event.Member;
public User User => Event.Member?.User ?? Event.User;
public string Token => Event.Token;
public string? CustomId => Event.Data?.CustomId;
public async Task Reply(string content)
{
await Respond(InteractionResponse.ResponseType.ChannelMessageWithSource,
new InteractionApplicationCommandCallbackData
{
Content = content,
Flags = Message.MessageFlags.Ephemeral
});
}
public async Task Reply(string content)
{
await Respond(InteractionResponse.ResponseType.ChannelMessageWithSource,
new InteractionApplicationCommandCallbackData { Content = content, Flags = Message.MessageFlags.Ephemeral });
}
public async Task Ignore()
{
await Respond(InteractionResponse.ResponseType.DeferredUpdateMessage, new InteractionApplicationCommandCallbackData
public async Task Ignore()
{
await Respond(InteractionResponse.ResponseType.DeferredUpdateMessage,
new InteractionApplicationCommandCallbackData
{
// Components = _evt.Message.Components
});
}
}
public async Task Acknowledge()
{
await Respond(InteractionResponse.ResponseType.UpdateMessage, new InteractionApplicationCommandCallbackData
{
Components = Array.Empty<MessageComponent>()
});
}
public async Task Acknowledge()
{
await Respond(InteractionResponse.ResponseType.UpdateMessage,
new InteractionApplicationCommandCallbackData { Components = Array.Empty<MessageComponent>() });
}
public async Task Respond(InteractionResponse.ResponseType type, InteractionApplicationCommandCallbackData? data)
{
var rest = _services.Resolve<DiscordApiClient>();
await rest.CreateInteractionResponse(_evt.Id, _evt.Token, new InteractionResponse { Type = type, Data = data });
}
public async Task Respond(InteractionResponse.ResponseType type,
InteractionApplicationCommandCallbackData? data)
{
var rest = _services.Resolve<DiscordApiClient>();
await rest.CreateInteractionResponse(Event.Id, Event.Token,
new InteractionResponse { Type = type, Data = data });
}
}

View File

@@ -1,122 +1,130 @@
using System;
using System.Globalization;
namespace PluralKit.Bot.Utils
namespace PluralKit.Bot.Utils;
// PK note: class is wholesale copied from Discord.NET (MIT-licensed)
// https://github.com/discord-net/Discord.Net/blob/ff0fea98a65d907fbce07856f1a9ef4aebb9108b/src/Discord.Net.Core/Utils/MentionUtils.cs
/// <summary>
/// Provides a series of helper methods for parsing mentions.
/// </summary>
public static class MentionUtils
{
// PK note: class is wholesale copied from Discord.NET (MIT-licensed)
// https://github.com/discord-net/Discord.Net/blob/ff0fea98a65d907fbce07856f1a9ef4aebb9108b/src/Discord.Net.Core/Utils/MentionUtils.cs
private const char SanitizeChar = '\x200b';
//If the system can't be positive a user doesn't have a nickname, assume useNickname = true (source: Jake)
internal static string MentionUser(string id, bool useNickname = true) =>
useNickname ? $"<@!{id}>" : $"<@{id}>";
/// <summary>
/// Provides a series of helper methods for parsing mentions.
/// Returns a mention string based on the user ID.
/// </summary>
public static class MentionUtils
/// <returns>
/// A user mention string (e.g. &lt;@80351110224678912&gt;).
/// </returns>
public static string MentionUser(ulong id) => MentionUser(id.ToString());
internal static string MentionChannel(string id) => $"<#{id}>";
/// <summary>
/// Returns a mention string based on the channel ID.
/// </summary>
/// <returns>
/// A channel mention string (e.g. &lt;#103735883630395392&gt;).
/// </returns>
public static string MentionChannel(ulong id) => MentionChannel(id.ToString());
internal static string MentionRole(string id) => $"<@&{id}>";
/// <summary>
/// Returns a mention string based on the role ID.
/// </summary>
/// <returns>
/// A role mention string (e.g. &lt;@&amp;165511591545143296&gt;).
/// </returns>
public static string MentionRole(ulong id) => MentionRole(id.ToString());
/// <summary>
/// Parses a provided user mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseUser(string text)
{
private const char SanitizeChar = '\x200b';
if (TryParseUser(text, out var id))
return id;
throw new ArgumentException("Invalid mention format.", nameof(text));
}
//If the system can't be positive a user doesn't have a nickname, assume useNickname = true (source: Jake)
internal static string MentionUser(string id, bool useNickname = true) => useNickname ? $"<@!{id}>" : $"<@{id}>";
/// <summary>
/// Returns a mention string based on the user ID.
/// </summary>
/// <returns>
/// A user mention string (e.g. &lt;@80351110224678912&gt;).
/// </returns>
public static string MentionUser(ulong id) => MentionUser(id.ToString(), true);
internal static string MentionChannel(string id) => $"<#{id}>";
/// <summary>
/// Returns a mention string based on the channel ID.
/// </summary>
/// <returns>
/// A channel mention string (e.g. &lt;#103735883630395392&gt;).
/// </returns>
public static string MentionChannel(ulong id) => MentionChannel(id.ToString());
internal static string MentionRole(string id) => $"<@&{id}>";
/// <summary>
/// Returns a mention string based on the role ID.
/// </summary>
/// <returns>
/// A role mention string (e.g. &lt;@&amp;165511591545143296&gt;).
/// </returns>
public static string MentionRole(ulong id) => MentionRole(id.ToString());
/// <summary>
/// Parses a provided user mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseUser(string text)
/// <summary>
/// Tries to parse a provided user mention string.
/// </summary>
public static bool TryParseUser(string text, out ulong userId)
{
if (text.Length >= 3 && text[0] == '<' && text[1] == '@' && text[text.Length - 1] == '>')
{
if (TryParseUser(text, out ulong id))
return id;
throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text));
}
/// <summary>
/// Tries to parse a provided user mention string.
/// </summary>
public static bool TryParseUser(string text, out ulong userId)
{
if (text.Length >= 3 && text[0] == '<' && text[1] == '@' && text[text.Length - 1] == '>')
{
if (text.Length >= 4 && text[2] == '!')
text = text.Substring(3, text.Length - 4); //<@!123>
else
text = text.Substring(2, text.Length - 3); //<@123>
if (text.Length >= 4 && text[2] == '!')
text = text.Substring(3, text.Length - 4); //<@!123>
else
text = text.Substring(2, text.Length - 3); //<@123>
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out userId))
return true;
}
userId = 0;
return false;
}
/// <summary>
/// Parses a provided channel mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseChannel(string text)
{
if (TryParseChannel(text, out ulong id))
return id;
throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text));
}
/// <summary>
/// Tries to parse a provided channel mention string.
/// </summary>
public static bool TryParseChannel(string text, out ulong channelId)
{
if (text.Length > 3 && text[0] == '<' && text[1] == '#' && text[text.Length - 1] == '>')
text = text.Substring(2, text.Length - 3); //<#123>
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out channelId))
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out userId))
return true;
channelId = 0;
return false;
}
/// <summary>
/// Parses a provided role mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseRole(string text)
userId = 0;
return false;
}
/// <summary>
/// Parses a provided channel mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseChannel(string text)
{
if (TryParseChannel(text, out var id))
return id;
throw new ArgumentException("Invalid mention format.", nameof(text));
}
/// <summary>
/// Tries to parse a provided channel mention string.
/// </summary>
public static bool TryParseChannel(string text, out ulong channelId)
{
if (text.Length > 3 && text[0] == '<' && text[1] == '#' && text[text.Length - 1] == '>')
text = text.Substring(2, text.Length - 3); //<#123>
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out channelId))
return true;
channelId = 0;
return false;
}
/// <summary>
/// Parses a provided role mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseRole(string text)
{
if (TryParseRole(text, out var id))
return id;
throw new ArgumentException("Invalid mention format.", nameof(text));
}
/// <summary>
/// Tries to parse a provided role mention string.
/// </summary>
public static bool TryParseRole(string text, out ulong roleId)
{
if (text.Length >= 4 && text[0] == '<' && text[1] == '@' && text[2] == '&' && text[text.Length - 1] == '>')
{
if (TryParseRole(text, out ulong id))
return id;
throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text));
}
/// <summary>
/// Tries to parse a provided role mention string.
/// </summary>
public static bool TryParseRole(string text, out ulong roleId)
{
if (text.Length >= 4 && text[0] == '<' && text[1] == '@' && text[2] == '&' && text[text.Length - 1] == '>')
{
text = text.Substring(3, text.Length - 4); //<@&123>
text = text.Substring(3, text.Length - 4); //<@&123>
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out roleId))
return true;
}
roleId = 0;
return false;
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out roleId))
return true;
}
roleId = 0;
return false;
}
}

View File

@@ -1,8 +1,4 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading.Tasks;
using Myriad.Rest.Exceptions;
@@ -14,63 +10,64 @@ using PluralKit.Core;
using Polly.Timeout;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class MiscUtils
{
public static class MiscUtils
public static string ProxyTagsString(this PKMember member, string separator = ", ") =>
string.Join(separator, member.ProxyTags.Select(t => t.ProxyString.AsCode()));
public static bool IsOurProblem(this Exception e)
{
public static string ProxyTagsString(this PKMember member, string separator = ", ") =>
string.Join(separator, member.ProxyTags.Select(t => t.ProxyString.AsCode()));
// This function filters out sporadic errors out of our control from being reported to Sentry
// otherwise we'd blow out our error reporting budget as soon as Discord takes a dump, or something.
public static bool IsOurProblem(this Exception e)
{
// This function filters out sporadic errors out of our control from being reported to Sentry
// otherwise we'd blow out our error reporting budget as soon as Discord takes a dump, or something.
// Occasionally Discord's API will Have A Bad Time and return a bunch of CloudFlare errors (in HTML format).
// The library tries to parse these HTML responses as JSON and crashes with a consistent exception message.
if (e is JsonReaderException jre && jre.Message ==
"Unexpected character encountered while parsing value: <. Path '', line 0, position 0.") return false;
// Occasionally Discord's API will Have A Bad Time and return a bunch of CloudFlare errors (in HTML format).
// The library tries to parse these HTML responses as JSON and crashes with a consistent exception message.
if (e is JsonReaderException jre && jre.Message == "Unexpected character encountered while parsing value: <. Path '', line 0, position 0.") return false;
// And now (2020-05-12), apparently Discord returns these weird responses occasionally. Also not our problem.
if (e is BadRequestException bre && bre.ResponseBody.Contains("<center>nginx</center>")) return false;
if (e is NotFoundException ne && ne.ResponseBody.Contains("<center>nginx</center>")) return false;
if (e is UnauthorizedException ue && ue.ResponseBody.Contains("<center>nginx</center>")) return false;
// And now (2020-05-12), apparently Discord returns these weird responses occasionally. Also not our problem.
if (e is BadRequestException bre && bre.ResponseBody.Contains("<center>nginx</center>")) return false;
if (e is NotFoundException ne && ne.ResponseBody.Contains("<center>nginx</center>")) return false;
if (e is UnauthorizedException ue && ue.ResponseBody.Contains("<center>nginx</center>")) return false;
// Filter out timeout/ratelimit related stuff
if (e is TooManyRequestsException) return false;
if (e is RatelimitBucketExhaustedException) return false;
if (e is TimeoutRejectedException) return false;
// Filter out timeout/ratelimit related stuff
if (e is TooManyRequestsException) return false;
if (e is RatelimitBucketExhaustedException) return false;
if (e is TimeoutRejectedException) return false;
// 5xxs? also not our problem :^)
if (e is UnknownDiscordRequestException udre && (int)udre.StatusCode >= 500) return false;
// 5xxs? also not our problem :^)
if (e is UnknownDiscordRequestException udre && (int)udre.StatusCode >= 500) return false;
// Webhook server errors are also *not our problem*
// (this includes rate limit errors, WebhookRateLimited is a subclass)
if (e is WebhookExecutionErrorOnDiscordsEnd) return false;
// Webhook server errors are also *not our problem*
// (this includes rate limit errors, WebhookRateLimited is a subclass)
if (e is WebhookExecutionErrorOnDiscordsEnd) return false;
// Socket errors are *not our problem*
if (e.GetBaseException() is SocketException) return false;
// Socket errors are *not our problem*
if (e.GetBaseException() is SocketException) return false;
// Tasks being cancelled for whatver reason are, you guessed it, also not our problem.
if (e is TaskCanceledException) return false;
// Tasks being cancelled for whatver reason are, you guessed it, also not our problem.
if (e is TaskCanceledException) return false;
// Sometimes Discord just times everything out.
if (e is TimeoutException) return false;
// Sometimes Discord just times everything out.
if (e is TimeoutException) return false;
// HTTP/2 streams are complicated and break sometimes.
if (e is HttpRequestException) return false;
// HTTP/2 streams are complicated and break sometimes.
if (e is HttpRequestException) return false;
// Ignore "Database is shutting down" error
if (e is PostgresException pe && pe.SqlState == "57P03") return false;
// Ignore "Database is shutting down" error
if (e is PostgresException pe && pe.SqlState == "57P03") return false;
// Ignore database timing out as well.
if (e is NpgsqlException tpe && tpe.InnerException is TimeoutException)
return false;
// Ignore database timing out as well.
if (e is NpgsqlException tpe && tpe.InnerException is TimeoutException)
return false;
// Ignore thread pool exhaustion errors
if (e is NpgsqlException npe && npe.Message.Contains("The connection pool has been exhausted"))
return false;
// Ignore thread pool exhaustion errors
if (e is NpgsqlException npe && npe.Message.Contains("The connection pool has been exhausted")) return false;
// This may expanded at some point.
return true;
}
// This may expanded at some point.
return true;
}
}

View File

@@ -1,46 +1,44 @@
using System.Linq;
using System.Text.RegularExpressions;
using PluralKit.Core;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class ModelUtils
{
public static class ModelUtils
public static string NameFor(this PKMember member, Context ctx) =>
member.NameFor(ctx.LookupContextFor(member));
public static string AvatarFor(this PKMember member, Context ctx) =>
member.AvatarFor(ctx.LookupContextFor(member)).TryGetCleanCdnUrl();
public static string DisplayName(this PKMember member) =>
member.DisplayName ?? member.Name;
public static string Reference(this PKMember member) => EntityReference(member.Hid, member.Name);
public static string Reference(this PKGroup group) => EntityReference(group.Hid, group.Name);
private static string EntityReference(string hid, string name)
{
public static string NameFor(this PKMember member, Context ctx) =>
member.NameFor(ctx.LookupContextFor(member));
bool IsSimple(string s) =>
// No spaces, no symbols, allow single quote but not at the start
Regex.IsMatch(s, "^[\\w\\d\\-_'?]+$") && !s.StartsWith("'");
public static string AvatarFor(this PKMember member, Context ctx) =>
member.AvatarFor(ctx.LookupContextFor(member)).TryGetCleanCdnUrl();
public static string DisplayName(this PKMember member) =>
member.DisplayName ?? member.Name;
public static string Reference(this PKMember member) => EntityReference(member.Hid, member.Name);
public static string Reference(this PKGroup group) => EntityReference(group.Hid, group.Name);
private static string EntityReference(string hid, string name)
{
bool IsSimple(string s) =>
// No spaces, no symbols, allow single quote but not at the start
Regex.IsMatch(s, "^[\\w\\d\\-_'?]+$") && !s.StartsWith("'");
// If it's very long (>25 chars), always use hid
if (name.Length >= 25)
return hid;
// If name is "simple" just use that
if (IsSimple(name))
return name;
// If three or fewer "words" and they're all simple individually, quote them
var words = name.Split(' ');
if (words.Length <= 3 && words.All(w => w.Length > 0 && IsSimple(w)))
// Words with double quotes are never "simple" so we're safe to naive-quote here
return $"\"{name}\"";
// Otherwise, just use hid
// If it's very long (>25 chars), always use hid
if (name.Length >= 25)
return hid;
}
// If name is "simple" just use that
if (IsSimple(name))
return name;
// If three or fewer "words" and they're all simple individually, quote them
var words = name.Split(' ');
if (words.Length <= 3 && words.All(w => w.Length > 0 && IsSimple(w)))
// Words with double quotes are never "simple" so we're safe to naive-quote here
return $"\"{name}\"";
// Otherwise, just use hid
return hid;
}
}

View File

@@ -1,103 +1,98 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Myriad.Extensions;
using Myriad.Gateway;
using Myriad.Types;
using Sentry;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public interface ISentryEnricher<T> where T : IGatewayEvent
{
public interface ISentryEnricher<T> where T : IGatewayEvent
void Enrich(Scope scope, Shard shard, T evt);
}
public class SentryEnricher:
ISentryEnricher<MessageCreateEvent>,
ISentryEnricher<MessageDeleteEvent>,
ISentryEnricher<MessageUpdateEvent>,
ISentryEnricher<MessageDeleteBulkEvent>,
ISentryEnricher<MessageReactionAddEvent>
{
private readonly Bot _bot;
public SentryEnricher(Bot bot)
{
void Enrich(Scope scope, Shard shard, T evt);
_bot = bot;
}
public class SentryEnricher:
ISentryEnricher<MessageCreateEvent>,
ISentryEnricher<MessageDeleteEvent>,
ISentryEnricher<MessageUpdateEvent>,
ISentryEnricher<MessageDeleteBulkEvent>,
ISentryEnricher<MessageReactionAddEvent>
// TODO: should this class take the Scope by dependency injection instead?
// Would allow us to create a centralized "chain of handlers" where this class could just be registered as an entry in
public void Enrich(Scope scope, Shard shard, MessageCreateEvent evt)
{
private readonly Bot _bot;
public SentryEnricher(Bot bot)
{
_bot = bot;
}
// TODO: should this class take the Scope by dependency injection instead?
// Would allow us to create a centralized "chain of handlers" where this class could just be registered as an entry in
public void Enrich(Scope scope, Shard shard, MessageCreateEvent evt)
{
scope.AddBreadcrumb(evt.Content, "event.message", data: new Dictionary<string, string>
scope.AddBreadcrumb(evt.Content, "event.message",
data: new Dictionary<string, string>
{
{"user", evt.Author.Id.ToString()},
{"channel", evt.ChannelId.ToString()},
{"guild", evt.GuildId.ToString()},
{"message", evt.Id.ToString()},
{"message", evt.Id.ToString()}
});
scope.SetTag("shard", shard.ShardId.ToString());
scope.SetTag("shard", shard.ShardId.ToString());
// Also report information about the bot's permissions in the channel
// We get a lot of permission errors so this'll be useful for determining problems
// Also report information about the bot's permissions in the channel
// We get a lot of permission errors so this'll be useful for determining problems
// todo: re-add this
// var perms = _bot.PermissionsIn(evt.ChannelId);
// scope.AddBreadcrumb(perms.ToPermissionString(), "permissions");
}
// todo: re-add this
// var perms = _bot.PermissionsIn(evt.ChannelId);
// scope.AddBreadcrumb(perms.ToPermissionString(), "permissions");
}
public void Enrich(Scope scope, Shard shard, MessageDeleteEvent evt)
{
scope.AddBreadcrumb("", "event.messageDelete",
data: new Dictionary<string, string>()
{
{"channel", evt.ChannelId.ToString()},
{"guild", evt.GuildId.ToString()},
{"message", evt.Id.ToString()},
});
scope.SetTag("shard", shard.ShardId.ToString());
}
public void Enrich(Scope scope, Shard shard, MessageDeleteBulkEvent evt)
{
scope.AddBreadcrumb("", "event.messageDelete",
data: new Dictionary<string, string>
{
{"channel", evt.ChannelId.ToString()},
{"guild", evt.GuildId.ToString()},
{"messages", string.Join(",", evt.Ids)}
});
scope.SetTag("shard", shard.ShardId.ToString());
}
public void Enrich(Scope scope, Shard shard, MessageUpdateEvent evt)
{
scope.AddBreadcrumb(evt.Content.Value ?? "<unknown>", "event.messageEdit",
data: new Dictionary<string, string>()
{
{"channel", evt.ChannelId.ToString()},
{"guild", evt.GuildId.Value.ToString()},
{"message", evt.Id.ToString()}
});
scope.SetTag("shard", shard.ShardId.ToString());
}
public void Enrich(Scope scope, Shard shard, MessageUpdateEvent evt)
{
scope.AddBreadcrumb(evt.Content.Value ?? "<unknown>", "event.messageEdit",
data: new Dictionary<string, string>
{
{"channel", evt.ChannelId.ToString()},
{"guild", evt.GuildId.Value.ToString()},
{"message", evt.Id.ToString()}
});
scope.SetTag("shard", shard.ShardId.ToString());
}
public void Enrich(Scope scope, Shard shard, MessageDeleteBulkEvent evt)
{
scope.AddBreadcrumb("", "event.messageDelete",
data: new Dictionary<string, string>()
{
{"channel", evt.ChannelId.ToString()},
{"guild", evt.GuildId.ToString()},
{"messages", string.Join(",", evt.Ids)},
});
scope.SetTag("shard", shard.ShardId.ToString());
}
public void Enrich(Scope scope, Shard shard, MessageDeleteEvent evt)
{
scope.AddBreadcrumb("", "event.messageDelete",
data: new Dictionary<string, string>
{
{"channel", evt.ChannelId.ToString()},
{"guild", evt.GuildId.ToString()},
{"message", evt.Id.ToString()}
});
scope.SetTag("shard", shard.ShardId.ToString());
}
public void Enrich(Scope scope, Shard shard, MessageReactionAddEvent evt)
{
scope.AddBreadcrumb("", "event.reaction",
data: new Dictionary<string, string>()
{
{"user", evt.UserId.ToString()},
{"channel", evt.ChannelId.ToString()},
{"guild", (evt.GuildId ?? 0).ToString()},
{"message", evt.MessageId.ToString()},
{"reaction", evt.Emoji.Name}
});
scope.SetTag("shard", shard.ShardId.ToString());
}
public void Enrich(Scope scope, Shard shard, MessageReactionAddEvent evt)
{
scope.AddBreadcrumb("", "event.reaction",
data: new Dictionary<string, string>
{
{"user", evt.UserId.ToString()},
{"channel", evt.ChannelId.ToString()},
{"guild", (evt.GuildId ?? 0).ToString()},
{"message", evt.MessageId.ToString()},
{"reaction", evt.Emoji.Name}
});
scope.SetTag("shard", shard.ShardId.ToString());
}
}

View File

@@ -1,111 +1,103 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Myriad.Cache;
using Myriad.Extensions;
using Myriad.Gateway;
using Myriad.Types;
using Serilog.Core;
using Serilog.Events;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public class SerilogGatewayEnricherFactory
{
public class SerilogGatewayEnricherFactory
private readonly Bot _bot;
private readonly IDiscordCache _cache;
public SerilogGatewayEnricherFactory(Bot bot, IDiscordCache cache)
{
private readonly Bot _bot;
private readonly IDiscordCache _cache;
_bot = bot;
_cache = cache;
}
public SerilogGatewayEnricherFactory(Bot bot, IDiscordCache cache)
public async Task<ILogEventEnricher> GetEnricher(Shard shard, IGatewayEvent evt)
{
var props = new List<LogEventProperty> { new("ShardId", new ScalarValue(shard.ShardId)) };
var (guild, channel) = GetGuildChannelId(evt);
var user = GetUserId(evt);
var message = GetMessageId(evt);
if (guild != null)
props.Add(new LogEventProperty("GuildId", new ScalarValue(guild.Value)));
if (channel != null)
{
_bot = bot;
_cache = cache;
props.Add(new LogEventProperty("ChannelId", new ScalarValue(channel.Value)));
if (await _cache.TryGetChannel(channel.Value) != null)
{
var botPermissions = await _cache.PermissionsIn(channel.Value);
props.Add(new LogEventProperty("BotPermissions", new ScalarValue(botPermissions)));
}
}
public async Task<ILogEventEnricher> GetEnricher(Shard shard, IGatewayEvent evt)
if (message != null)
props.Add(new LogEventProperty("MessageId", new ScalarValue(message.Value)));
if (user != null)
props.Add(new LogEventProperty("UserId", new ScalarValue(user.Value)));
if (evt is MessageCreateEvent mce)
props.Add(new LogEventProperty("UserPermissions", new ScalarValue(await _cache.PermissionsFor(mce))));
return new Inner(props);
}
private (ulong?, ulong?) GetGuildChannelId(IGatewayEvent evt) => evt switch
{
ChannelCreateEvent e => (e.GuildId, e.Id),
ChannelUpdateEvent e => (e.GuildId, e.Id),
ChannelDeleteEvent e => (e.GuildId, e.Id),
MessageCreateEvent e => (e.GuildId, e.ChannelId),
MessageUpdateEvent e => (e.GuildId.Value, e.ChannelId),
MessageDeleteEvent e => (e.GuildId, e.ChannelId),
MessageDeleteBulkEvent e => (e.GuildId, e.ChannelId),
MessageReactionAddEvent e => (e.GuildId, e.ChannelId),
MessageReactionRemoveEvent e => (e.GuildId, e.ChannelId),
MessageReactionRemoveAllEvent e => (e.GuildId, e.ChannelId),
MessageReactionRemoveEmojiEvent e => (e.GuildId, e.ChannelId),
InteractionCreateEvent e => (e.GuildId, e.ChannelId),
_ => (null, null)
};
private ulong? GetUserId(IGatewayEvent evt) => evt switch
{
MessageCreateEvent e => e.Author.Id,
MessageUpdateEvent e => e.Author.HasValue ? e.Author.Value.Id : null,
MessageReactionAddEvent e => e.UserId,
MessageReactionRemoveEvent e => e.UserId,
InteractionCreateEvent e => e.User?.Id ?? e.Member.User.Id,
_ => null
};
private ulong? GetMessageId(IGatewayEvent evt) => evt switch
{
MessageCreateEvent e => e.Id,
MessageUpdateEvent e => e.Id,
MessageDeleteEvent e => e.Id,
MessageReactionAddEvent e => e.MessageId,
MessageReactionRemoveEvent e => e.MessageId,
MessageReactionRemoveAllEvent e => e.MessageId,
MessageReactionRemoveEmojiEvent e => e.MessageId,
InteractionCreateEvent e => e.Message?.Id,
_ => null
};
private record Inner(List<LogEventProperty> Properties): ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var props = new List<LogEventProperty>
{
new("ShardId", new ScalarValue(shard.ShardId)),
};
var (guild, channel) = GetGuildChannelId(evt);
var user = GetUserId(evt);
var message = GetMessageId(evt);
if (guild != null)
props.Add(new("GuildId", new ScalarValue(guild.Value)));
if (channel != null)
{
props.Add(new("ChannelId", new ScalarValue(channel.Value)));
if (await _cache.TryGetChannel(channel.Value) != null)
{
var botPermissions = await _cache.PermissionsIn(channel.Value);
props.Add(new("BotPermissions", new ScalarValue(botPermissions)));
}
}
if (message != null)
props.Add(new("MessageId", new ScalarValue(message.Value)));
if (user != null)
props.Add(new("UserId", new ScalarValue(user.Value)));
if (evt is MessageCreateEvent mce)
props.Add(new("UserPermissions", new ScalarValue(await _cache.PermissionsFor(mce))));
return new Inner(props);
}
private (ulong?, ulong?) GetGuildChannelId(IGatewayEvent evt) => evt switch
{
ChannelCreateEvent e => (e.GuildId, e.Id),
ChannelUpdateEvent e => (e.GuildId, e.Id),
ChannelDeleteEvent e => (e.GuildId, e.Id),
MessageCreateEvent e => (e.GuildId, e.ChannelId),
MessageUpdateEvent e => (e.GuildId.Value, e.ChannelId),
MessageDeleteEvent e => (e.GuildId, e.ChannelId),
MessageDeleteBulkEvent e => (e.GuildId, e.ChannelId),
MessageReactionAddEvent e => (e.GuildId, e.ChannelId),
MessageReactionRemoveEvent e => (e.GuildId, e.ChannelId),
MessageReactionRemoveAllEvent e => (e.GuildId, e.ChannelId),
MessageReactionRemoveEmojiEvent e => (e.GuildId, e.ChannelId),
InteractionCreateEvent e => (e.GuildId, e.ChannelId),
_ => (null, null)
};
private ulong? GetUserId(IGatewayEvent evt) => evt switch
{
MessageCreateEvent e => e.Author.Id,
MessageUpdateEvent e => e.Author.HasValue ? e.Author.Value.Id : null,
MessageReactionAddEvent e => e.UserId,
MessageReactionRemoveEvent e => e.UserId,
InteractionCreateEvent e => e.User?.Id ?? e.Member.User.Id,
_ => null,
};
private ulong? GetMessageId(IGatewayEvent evt) => evt switch
{
MessageCreateEvent e => e.Id,
MessageUpdateEvent e => e.Id,
MessageDeleteEvent e => e.Id,
MessageReactionAddEvent e => e.MessageId,
MessageReactionRemoveEvent e => e.MessageId,
MessageReactionRemoveAllEvent e => e.MessageId,
MessageReactionRemoveEmojiEvent e => e.MessageId,
InteractionCreateEvent e => e.Message?.Id,
_ => null,
};
private record Inner(List<LogEventProperty> Properties): ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
foreach (var prop in Properties)
logEvent.AddPropertyIfAbsent(prop);
}
foreach (var prop in Properties)
logEvent.AddPropertyIfAbsent(prop);
}
}
}