PluralKit/PluralKit.Bot/Utils/DiscordUtils.cs

231 lines
8.4 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
using Myriad.Builders;
using Myriad.Extensions;
using Myriad.Gateway;
using Myriad.Rest;
using Myriad.Rest.Exceptions;
using Myriad.Rest.Types;
using Myriad.Types;
using NodaTime;
using PluralKit.Core;
namespace PluralKit.Bot;
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)
{
foreach (var reaction in reactions)
await rest.CreateReaction(msg.ChannelId, msg.Id, new Emoji { Name = reaction });
}
public static async Task<Guild?> GetGuildOrNull(this DiscordApiClient rest, ulong guildId)
{
try
{
return await rest.GetGuild(guildId);
}
catch (ForbiddenException)
{
// no permission, couldn't fetch, oh well
return null;
}
}
public static async Task<Channel?> GetChannelOrNull(this DiscordApiClient rest, ulong channelId)
{
try
{
return await rest.GetChannel(channelId);
}
catch (ForbiddenException)
{
// no permission, couldn't fetch, oh well
return null;
}
}
public static async Task<Message?> GetMessageOrNull(this DiscordApiClient rest, ulong channelId,
ulong messageId)
{
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;
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)
{
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)
{
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 ? 4096 : 1024;
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 or
Channel.ChannelType.GuildStageVoice;
}