231 lines
8.4 KiB
C#
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;
|
|
} |