From 79e43c414449770a83dd0bc1318469ef78a3f050 Mon Sep 17 00:00:00 2001 From: Will Kennedy Date: Thu, 26 Mar 2020 08:36:55 -0400 Subject: [PATCH] Add support for Guild Member object & included data (#279) --- DiscordChatExporter.Cli/Program.cs | 1 + DiscordChatExporter.Core.Models/Guild.cs | 27 ++++++++++++++-- DiscordChatExporter.Core.Models/Member.cs | 20 ++++++++++++ DiscordChatExporter.Core.Models/Role.cs | 18 +++++++++-- .../Formatters/HtmlMessageWriter.cs | 4 +++ .../Logic/HtmlRenderingLogic.cs | 6 ++-- .../Resources/HtmlMessageGroupTemplate.html | 2 +- .../DataService.Parsers.cs | 18 +++++++++-- .../DataService.cs | 32 +++++++++++-------- .../ExportService.cs | 19 +++++++++-- 10 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 DiscordChatExporter.Core.Models/Member.cs diff --git a/DiscordChatExporter.Cli/Program.cs b/DiscordChatExporter.Cli/Program.cs index 16841ff..fbac83e 100644 --- a/DiscordChatExporter.Cli/Program.cs +++ b/DiscordChatExporter.Cli/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Drawing; using System.Threading.Tasks; using CliFx; using DiscordChatExporter.Cli.Commands; diff --git a/DiscordChatExporter.Core.Models/Guild.cs b/DiscordChatExporter.Core.Models/Guild.cs index 0f31838..33df8b6 100644 --- a/DiscordChatExporter.Core.Models/Guild.cs +++ b/DiscordChatExporter.Core.Models/Guild.cs @@ -1,4 +1,9 @@ -namespace DiscordChatExporter.Core.Models +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; + +namespace DiscordChatExporter.Core.Models { // https://discordapp.string.IsNullOrWhiteSpace(com/developers/docs/resources/guild#guild-object @@ -10,13 +15,19 @@ public string? IconHash { get; } + public IReadOnlyList Roles { get; } + + public Dictionary Members { get; } + public string IconUrl { get; } - public Guild(string id, string name, string? iconHash) + public Guild(string id, string name, IReadOnlyList roles, string? iconHash) { Id = id; Name = name; IconHash = iconHash; + Roles = roles; + Members = new Dictionary(); IconUrl = GetIconUrl(id, iconHash); } @@ -26,6 +37,16 @@ public partial class Guild { + public static string GetUserColor(Guild guild, User user) => + guild.Members.GetValueOrDefault(user.Id, null)?.Roles + ?.Select(r => guild.Roles + .Where(role => r == role.Id) + .FirstOrDefault() + )?.Where(r => r.Color != Color.Black)? + .Aggregate(null, (a, b) => (a?.Position ?? 0) > b.Position? a : b)? + .ColorAsHex ?? ""; + public static string GetUserNick(Guild guild, User user) => guild.Members.GetValueOrDefault(user.Id)?.Nick ?? user.Name; + public static string GetIconUrl(string id, string? iconHash) { return !string.IsNullOrWhiteSpace(iconHash) @@ -33,6 +54,6 @@ : "https://cdn.discordapp.com/embed/avatars/0.png"; } - public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null); + public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", Array.Empty(), null); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Member.cs b/DiscordChatExporter.Core.Models/Member.cs new file mode 100644 index 0000000..35b1524 --- /dev/null +++ b/DiscordChatExporter.Core.Models/Member.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Linq; + +namespace DiscordChatExporter.Core.Models +{ + public class Member + { + public string UserId { get; } + public string? Nick { get; } + + public IReadOnlyList Roles { get; } + + public Member(string userId, string? nick, IReadOnlyList roles) + { + UserId = userId; + Nick = nick; + Roles = roles; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Role.cs b/DiscordChatExporter.Core.Models/Role.cs index 5409f21..af0a4ed 100644 --- a/DiscordChatExporter.Core.Models/Role.cs +++ b/DiscordChatExporter.Core.Models/Role.cs @@ -1,4 +1,7 @@ -namespace DiscordChatExporter.Core.Models + +using System.Drawing; + +namespace DiscordChatExporter.Core.Models { // https://discordapp.com/developers/docs/topics/permissions#role-object @@ -8,10 +11,19 @@ public string Name { get; } - public Role(string id, string name) + public Color Color { get; } + + public string ColorAsHex => $"#{(Color.ToArgb() & 0xffffff):X6}"; + public string ColorAsRgb => $"{Color.R}, {Color.G}, {Color.B}"; + + public int Position { get; } + + public Role(string id, string name, Color color, int position) { Id = id; Name = name; + Color = color; + Position = position; } public override string ToString() => Name; @@ -19,6 +31,6 @@ public partial class Role { - public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role"); + public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role", Color.Black, -1); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Formatters/HtmlMessageWriter.cs b/DiscordChatExporter.Core.Rendering/Formatters/HtmlMessageWriter.cs index ed12e38..f180870 100644 --- a/DiscordChatExporter.Core.Rendering/Formatters/HtmlMessageWriter.cs +++ b/DiscordChatExporter.Core.Rendering/Formatters/HtmlMessageWriter.cs @@ -75,6 +75,10 @@ namespace DiscordChatExporter.Core.Rendering.Formatters scriptObject.Import("FormatMarkdown", new Func(m => HtmlRenderingLogic.FormatMarkdown(Context, m))); + scriptObject.Import("GetUserColor", new Func(Guild.GetUserColor)); + + scriptObject.Import("GetUserNick", new Func(Guild.GetUserNick)); + // Push model templateContext.PushGlobal(scriptObject); diff --git a/DiscordChatExporter.Core.Rendering/Logic/HtmlRenderingLogic.cs b/DiscordChatExporter.Core.Rendering/Logic/HtmlRenderingLogic.cs index cd118bb..8716b89 100644 --- a/DiscordChatExporter.Core.Rendering/Logic/HtmlRenderingLogic.cs +++ b/DiscordChatExporter.Core.Rendering/Logic/HtmlRenderingLogic.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.Linq; using System.Net; using System.Text.RegularExpressions; @@ -114,8 +115,9 @@ namespace DiscordChatExporter.Core.Rendering.Logic { var role = context.MentionableRoles.FirstOrDefault(r => r.Id == mentionNode.Id) ?? Role.CreateDeletedRole(mentionNode.Id); - - return $"@{HtmlEncode(role.Name)}"; + string style = ""; + if (role.Color != Color.Black) style = $"style=\"color: {role.ColorAsHex}; background-color: rgba({role.ColorAsRgb}, 0.1); font-weight: 400;\""; + return $"@{HtmlEncode(role.Name)}"; } } diff --git a/DiscordChatExporter.Core.Rendering/Resources/HtmlMessageGroupTemplate.html b/DiscordChatExporter.Core.Rendering/Resources/HtmlMessageGroupTemplate.html index 00eda12..6db118e 100644 --- a/DiscordChatExporter.Core.Rendering/Resources/HtmlMessageGroupTemplate.html +++ b/DiscordChatExporter.Core.Rendering/Resources/HtmlMessageGroupTemplate.html @@ -5,7 +5,7 @@
{{~ # Author name and timestamp ~}} - {{ MessageGroup.Author.Name | html.escape }} + {{ GetUserNick Context.Guild MessageGroup.Author | html.escape }} {{~ # Bot tag ~}} {{~ if MessageGroup.Author.IsBot ~}} diff --git a/DiscordChatExporter.Core.Services/DataService.Parsers.cs b/DiscordChatExporter.Core.Services/DataService.Parsers.cs index 3b764a5..53dacaa 100644 --- a/DiscordChatExporter.Core.Services/DataService.Parsers.cs +++ b/DiscordChatExporter.Core.Services/DataService.Parsers.cs @@ -21,13 +21,23 @@ namespace DiscordChatExporter.Core.Services return new User(id, discriminator, name, avatarHash, isBot); } + private Member ParseMember(JToken json) + { + var userId = ParseUser(json["user"]!).Id; + var nick = json["nick"]?.Value(); + var roles = json["roles"]!.Select(jt => jt.Value()).ToArray(); + + return new Member(userId, nick, roles); + } + private Guild ParseGuild(JToken json) { var id = json["id"]!.Value(); var name = json["name"]!.Value(); var iconHash = json["icon"]!.Value(); + var roles = json["roles"]!.Select(ParseRole).ToList(); - return new Guild(id, name, iconHash); + return new Guild(id, name, roles, iconHash); } private Channel ParseChannel(JToken json) @@ -64,8 +74,10 @@ namespace DiscordChatExporter.Core.Services { var id = json["id"]!.Value(); var name = json["name"]!.Value(); + var color = json["color"]!.Value(); + var position = json["position"]!.Value(); - return new Role(id, name); + return new Role(id, name, Color.FromArgb(color), position); } private Attachment ParseAttachment(JToken json) @@ -193,7 +205,7 @@ namespace DiscordChatExporter.Core.Services // Get author var author = ParseUser(json["author"]!); - + // Get attachments var attachments = (json["attachments"] ?? Enumerable.Empty()).Select(ParseAttachment).ToArray(); diff --git a/DiscordChatExporter.Core.Services/DataService.cs b/DiscordChatExporter.Core.Services/DataService.cs index 2b0ff7d..4ea03f7 100644 --- a/DiscordChatExporter.Core.Services/DataService.cs +++ b/DiscordChatExporter.Core.Services/DataService.cs @@ -40,8 +40,12 @@ namespace DiscordChatExporter.Core.Services }, (response, timespan, retryCount, context) => Task.CompletedTask); } - + private async Task GetApiResponseAsync(AuthToken token, string route) + { + return (await GetApiResponseAsync(token, route, true))!; + } + private async Task GetApiResponseAsync(AuthToken token, string route, bool errorOnFail) { using var response = await _httpPolicy.ExecuteAsync(async () => { @@ -56,7 +60,10 @@ namespace DiscordChatExporter.Core.Services // We throw our own exception here because default one doesn't have status code if (!response.IsSuccessStatusCode) - throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase); + { + if(errorOnFail) throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase); + else return null; + } var jsonRaw = await response.Content.ReadAsStringAsync(); return JToken.Parse(jsonRaw); @@ -74,6 +81,15 @@ namespace DiscordChatExporter.Core.Services return guild; } + public async Task GetGuildMemberAsync(AuthToken token, string guildId, string userId) + { + var response = await GetApiResponseAsync(token, $"guilds/{guildId}/members/{userId}", false); + if(response == null) return null; + var member = ParseMember(response); + + return member; + } + public async Task GetChannelAsync(AuthToken token, string channelId) { var response = await GetApiResponseAsync(token, $"channels/{channelId}"); @@ -125,18 +141,6 @@ namespace DiscordChatExporter.Core.Services return channels; } - public async Task> GetGuildRolesAsync(AuthToken token, string guildId) - { - // Special case for direct messages pseudo-guild - if (guildId == Guild.DirectMessages.Id) - return Array.Empty(); - - var response = await GetApiResponseAsync(token, $"guilds/{guildId}/roles"); - var roles = response.Select(ParseRole).ToArray(); - - return roles; - } - private async Task GetLastMessageAsync(AuthToken token, string channelId, DateTimeOffset? before = null) { var route = $"channels/{channelId}/messages?limit=1"; diff --git a/DiscordChatExporter.Core.Services/ExportService.cs b/DiscordChatExporter.Core.Services/ExportService.cs index 3f1f43b..e305b23 100644 --- a/DiscordChatExporter.Core.Services/ExportService.cs +++ b/DiscordChatExporter.Core.Services/ExportService.cs @@ -34,7 +34,7 @@ namespace DiscordChatExporter.Core.Services // Create context var mentionableUsers = new HashSet(IdBasedEqualityComparer.Instance); var mentionableChannels = await _dataService.GetGuildChannelsAsync(token, guild.Id); - var mentionableRoles = await _dataService.GetGuildRolesAsync(token, guild.Id); + var mentionableRoles = guild.Roles; var context = new RenderContext ( @@ -50,8 +50,21 @@ namespace DiscordChatExporter.Core.Services await foreach (var message in _dataService.GetMessagesAsync(token, channel.Id, after, before, progress)) { // Add encountered users to the list of mentionable users - mentionableUsers.Add(message.Author); - mentionableUsers.AddRange(message.MentionedUsers); + var encounteredUsers = new List(); + encounteredUsers.Add(message.Author); + encounteredUsers.AddRange(message.MentionedUsers); + + mentionableUsers.AddRange(encounteredUsers); + + foreach (User u in encounteredUsers) + { + if(!guild.Members.ContainsKey(u.Id)) + { + var member = await _dataService.GetGuildMemberAsync(token, guild.Id, u.Id); + guild.Members[u.Id] = member; + } + } + // Render message await renderer.RenderMessageAsync(message);