using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using System.Threading.Tasks; using DSharpPlus; using DSharpPlus.Entities; using DSharpPlus.EventArgs; using DSharpPlus.Exceptions; using Myriad.Builders; using Myriad.Extensions; using Myriad.Rest; using Myriad.Rest.Types; using Myriad.Types; using NodaTime; using PluralKit.Core; using Permissions = DSharpPlus.Permissions; namespace PluralKit.Bot { public static class DiscordUtils { public static DiscordColor Blue = new DiscordColor(0x1f99d8); public static DiscordColor Green = new DiscordColor(0x00cc78); public static DiscordColor Red = new DiscordColor(0xef4b3d); public static DiscordColor Gray = new DiscordColor(0x979c9f); public static Permissions DM_PERMISSIONS = (Permissions) 0b00000_1000110_1011100110000_000000; 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 ? at the start/end; they need to be handled specially later... private static readonly Regex UNBROKEN_LINK_REGEX = new Regex("?"); private static readonly FieldInfo _roleIdsField = typeof(DiscordMember).GetField("_role_ids", BindingFlags.NonPublic | BindingFlags.Instance); public static string NameAndMention(this DiscordUser user) { return $"{user.Username}#{user.Discriminator} ({user.Mention})"; } public static string NameAndMention(this User user) { return $"{user.Username}#{user.Discriminator} ({user.Mention()})"; } // We funnel all "permissions from DiscordMember" calls through here // This way we can ensure we do the read permission correction everywhere private static Permissions PermissionsInGuild(DiscordChannel channel, DiscordMember member) { ValidateCachedRoles(member); var permissions = channel.PermissionsFor(member); // This method doesn't account for channels without read permissions // If we don't have read permissions in the channel, we don't have *any* permissions if ((permissions & Permissions.AccessChannels) != Permissions.AccessChannels) return Permissions.None; return permissions; } // Workaround for DSP internal error private static void ValidateCachedRoles(DiscordMember member) { var roleIdCache = _roleIdsField.GetValue(member) as List; var currentRoleIds = member.Roles.Where(x => x != null).Select(x => x.Id); var invalidRoleIds = roleIdCache.Where(x => !currentRoleIds.Contains(x)).ToList(); roleIdCache.RemoveAll(x => invalidRoleIds.Contains(x)); } // Same as PermissionsIn, but always synchronous. DiscordUser must be a DiscordMember if channel is in guild. public static Permissions PermissionsInSync(this DiscordChannel channel, DiscordUser user) { if (channel.Guild != null && !(user is DiscordMember)) throw new ArgumentException("Function was passed a guild channel but a non-member DiscordUser"); if (user is DiscordMember m) return PermissionsInGuild(channel, m); if (channel.Type == ChannelType.Private) return DM_PERMISSIONS; return Permissions.None; } public static Permissions BotPermissions(this DiscordChannel channel) { // TODO: can we get a CurrentMember somehow without a guild context? // at least, without somehow getting a DiscordClient reference as an arg(which I don't want to do) if (channel.Guild != null) return PermissionsInSync(channel, channel.Guild.CurrentMember); if (channel.Type == ChannelType.Private) return DM_PERMISSIONS; return Permissions.None; } public static bool BotHasAllPermissions(this DiscordChannel channel, Permissions permissionSet) => (BotPermissions(channel) & permissionSet) == permissionSet; 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 ulong InstantToSnowflake(DateTimeOffset time) => (ulong) (time - new DateTimeOffset(2015, 1, 1, 0, 0, 0, TimeSpan.Zero)).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() {Name = reaction}); } } public static string WorkaroundForUrlBug(string input) { // Workaround for https://github.com/DSharpPlus/DSharpPlus/issues/565 return input?.Replace("%20", "+"); } public static Task SendMessageFixedAsync(this DiscordChannel channel, string content = null, DiscordEmbed embed = null, IEnumerable mentions = null) => // Passing an empty list blocks all mentions by default (null allows all through) channel.SendMessageAsync(content, embed: embed, mentions: mentions ?? new IMention[0]); // This doesn't do anything by itself (DiscordMember.SendMessageAsync doesn't take a mentions argument) // It's just here for consistency so we don't use the standard SendMessageAsync method >.> public static Task SendMessageFixedAsync(this DiscordMember member, string content = null, DiscordEmbed embed = null) => member.SendMessageAsync(content, embed: embed); public static bool TryGetCachedUser(this DiscordClient client, ulong id, out DiscordUser user) { user = null; var cache = (ConcurrentDictionary) typeof(BaseDiscordClient) .GetProperty("UserCache", BindingFlags.Instance | BindingFlags.NonPublic) ?.GetValue(client); return cache != null && cache.TryGetValue(id, out user); } public static DiscordColor? ToDiscordColor(this string color) { if (int.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) return new DiscordColor(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; 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.ToArray(), Roles = roles.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 Task GetUser(this DiscordRestClient client, ulong id) => WrapDiscordCall(client.GetUserAsync(id)); public static Task GetUser(this DiscordClient client, ulong id) => WrapDiscordCall(client.GetUserAsync(id)); public static Task GetChannel(this DiscordRestClient client, ulong id) => WrapDiscordCall(client.GetChannelAsync(id)); public static Task GetChannel(this DiscordClient client, ulong id) => WrapDiscordCall(client.GetChannelAsync(id)); public static Task GetGuild(this DiscordRestClient client, ulong id) => WrapDiscordCall(client.GetGuildAsync(id)); public static Task GetGuild(this DiscordClient client, ulong id) => WrapDiscordCall(client.GetGuildAsync(id)); public static Task GetMember(this DiscordRestClient client, ulong guild, ulong user) { async Task Inner() => await (await client.GetGuildAsync(guild)).GetMemberAsync(user); return WrapDiscordCall(Inner()); } public static Task GetMember(this DiscordClient client, ulong guild, ulong user) { async Task Inner() => await (await client.GetGuildAsync(guild)).GetMemberAsync(user); return WrapDiscordCall(Inner()); } public static Task GetMember(this DiscordGuild guild, ulong user) => WrapDiscordCall(guild.GetMemberAsync(user)); public static Task GetMessage(this DiscordChannel channel, ulong id) => WrapDiscordCall(channel.GetMessageAsync(id)); public static Task GetMessage(this DiscordRestClient client, ulong channel, ulong message) => WrapDiscordCall(client.GetMessageAsync(channel, message)); public static DiscordGuild GetGuild(this DiscordShardedClient client, ulong id) { DiscordGuild guild; foreach (DiscordClient shard in client.ShardClients.Values) { shard.Guilds.TryGetValue(id, out guild); if (guild != null) return guild; } return null; } public static async Task GetChannel(this DiscordShardedClient client, ulong id, ulong? guildId = null) { // we need to know the channel's guild ID to get the cached guild object, so we grab it from the API if (guildId == null) { var channel = await WrapDiscordCall(client.ShardClients.Values.FirstOrDefault().GetChannelAsync(id)); if (channel != null) guildId = channel.GuildId; else return null; // we probably don't have the guild in cache if the API doesn't give it to us } return client.GetGuild(guildId.Value).GetChannel(id); } private static async Task WrapDiscordCall(Task t) where T: class { try { return await t; } catch (NotFoundException) { return null; } catch (UnauthorizedException) { return null; } } public static EmbedBuilder WithSimpleLineContent(this EmbedBuilder eb, IEnumerable 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 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 DiscordEventArgs evt) => evt.GetType().Name.Replace("EventArgs", ""); } }