feat: upgrade to .NET 6, refactor everything
This commit is contained in:
@@ -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);
|
||||
}
|
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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()}.";
|
||||
}
|
||||
}
|
@@ -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 });
|
||||
}
|
||||
}
|
@@ -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. <@80351110224678912>).
|
||||
/// </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. <#103735883630395392>).
|
||||
/// </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. <@&165511591545143296>).
|
||||
/// </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. <@80351110224678912>).
|
||||
/// </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. <#103735883630395392>).
|
||||
/// </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. <@&165511591545143296>).
|
||||
/// </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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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());
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user