diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj index 01d67d2d..e7fc37c7 100644 --- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj +++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj @@ -5,9 +5,14 @@ 2.4.1 + + + + + diff --git a/DiscordChatExporter.Core/Models/Embed.cs b/DiscordChatExporter.Core/Models/Embed.cs new file mode 100644 index 00000000..f9516c3e --- /dev/null +++ b/DiscordChatExporter.Core/Models/Embed.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Drawing; + +// https://discordapp.com/developers/docs/resources/channel#embed-object + +namespace DiscordChatExporter.Core.Models +{ + public class Embed : IMentionable + { + public string Title { get; } + + public string Type { get; } + + public string Description { get; } + + public string Url { get; } + + public DateTime? TimeStamp { get; } + + public Color? Color { get; } + + public EmbedFooter Footer { get; } + + public EmbedImage Image { get; } + + public EmbedImage Thumbnail { get; } + + public EmbedVideo Video { get; } + + public EmbedProvider Provider { get; } + + public EmbedAuthor Author { get; } + + public IReadOnlyList Fields { get; } + + public List MentionedUsers { get; } + + public List MentionedRoles { get; } + + public List MentionedChannels { get; } + + public Embed(string title, string type, string description, + string url, DateTime? timeStamp, Color? color, + EmbedFooter footer, EmbedImage image, EmbedImage thumbnail, + EmbedVideo video, EmbedProvider provider, EmbedAuthor author, + List fields, List mentionedUsers, + List mentionedRoles, List mentionedChannels) + { + Title = title; + Type = type; + Description = description; + Url = url; + TimeStamp = timeStamp; + Color = color; + Footer = footer; + Image = image; + Thumbnail = thumbnail; + Video = video; + Provider = provider; + Author = author; + Fields = fields; + MentionedUsers = mentionedUsers; + MentionedRoles = mentionedRoles; + MentionedChannels = mentionedChannels; + } + + public override string ToString() + { + return Description; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedAuthor.cs b/DiscordChatExporter.Core/Models/EmbedAuthor.cs new file mode 100644 index 00000000..8adbaaa8 --- /dev/null +++ b/DiscordChatExporter.Core/Models/EmbedAuthor.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure + +namespace DiscordChatExporter.Core.Models +{ + public class EmbedAuthor + { + public string Name { get; } + + public string Url { get; } + + public string IconUrl { get; } + + public string ProxyIconUrl { get; } + + public EmbedAuthor(string name, string url, string iconUrl, string proxyIconUrl) + { + Name = name; + Url = url; + IconUrl = iconUrl; + ProxyIconUrl = proxyIconUrl; + } + + public override string ToString() + { + return Name; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedField.cs b/DiscordChatExporter.Core/Models/EmbedField.cs new file mode 100644 index 00000000..ceb317ca --- /dev/null +++ b/DiscordChatExporter.Core/Models/EmbedField.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure + +namespace DiscordChatExporter.Core.Models +{ + public class EmbedField + { + public string Name { get; } + + public string Value { get; } + + public bool? Inline { get; } + + public EmbedField(string name, string value, bool? inline) + { + Name = name; + Value = value; + Inline = inline; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedFooter.cs b/DiscordChatExporter.Core/Models/EmbedFooter.cs new file mode 100644 index 00000000..10d6664c --- /dev/null +++ b/DiscordChatExporter.Core/Models/EmbedFooter.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; + +// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure + +namespace DiscordChatExporter.Core.Models +{ + public class EmbedFooter + { + public string Text { get; } + + public string IconUrl { get; } + + public string ProxyIconUrl { get; } + + public EmbedFooter(string text, string iconUrl, string proxyIconUrl) + { + Text = text; + IconUrl = iconUrl; + ProxyIconUrl = proxyIconUrl; + } + + public override string ToString() + { + return Text; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedImage.cs b/DiscordChatExporter.Core/Models/EmbedImage.cs new file mode 100644 index 00000000..118d3848 --- /dev/null +++ b/DiscordChatExporter.Core/Models/EmbedImage.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure + +namespace DiscordChatExporter.Core.Models +{ + public class EmbedImage + { + public string Url { get; } + + public string ProxyUrl { get; } + + public int? Height { get; } + + public int? Width { get; } + + public EmbedImage(string url, string proxyUrl, int? height, int? width) + { + Url = url; + ProxyUrl = proxyUrl; + Height = height; + Width = width; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedProvider.cs b/DiscordChatExporter.Core/Models/EmbedProvider.cs new file mode 100644 index 00000000..4dedf424 --- /dev/null +++ b/DiscordChatExporter.Core/Models/EmbedProvider.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-provider-structure + +namespace DiscordChatExporter.Core.Models +{ + public class EmbedProvider + { + public string Name { get; } + + public string Url { get; } + + public EmbedProvider(string name, string url) + { + Name = name; + Url = url; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedVideo.cs b/DiscordChatExporter.Core/Models/EmbedVideo.cs new file mode 100644 index 00000000..c55e2882 --- /dev/null +++ b/DiscordChatExporter.Core/Models/EmbedVideo.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-video-structure + +namespace DiscordChatExporter.Core.Models +{ + public class EmbedVideo + { + public string Url { get; } + + public int? Height { get; } + + public int? Width { get; } + + public EmbedVideo(string url, int? height, int? width) + { + Url = url; + Height = height; + Width = width; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/IMentionable.cs b/DiscordChatExporter.Core/Models/IMentionable.cs new file mode 100644 index 00000000..388d6416 --- /dev/null +++ b/DiscordChatExporter.Core/Models/IMentionable.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DiscordChatExporter.Core.Models +{ + interface IMentionable + { + List MentionedUsers { get; } + + List MentionedRoles { get; } + + List MentionedChannels { get; } + } +} diff --git a/DiscordChatExporter.Core/Models/Message.cs b/DiscordChatExporter.Core/Models/Message.cs index 18b1aa43..9c44536c 100644 --- a/DiscordChatExporter.Core/Models/Message.cs +++ b/DiscordChatExporter.Core/Models/Message.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; namespace DiscordChatExporter.Core.Models { - public class Message + public class Message : IMentionable { public string Id { get; } @@ -21,17 +21,20 @@ namespace DiscordChatExporter.Core.Models public IReadOnlyList Attachments { get; } - public IReadOnlyList MentionedUsers { get; } + public IReadOnlyList Embeds { get; } - public IReadOnlyList MentionedRoles { get; } + public List MentionedUsers { get; } - public IReadOnlyList MentionedChannels { get; } + public List MentionedRoles { get; } + + public List MentionedChannels { get; } public Message(string id, string channelId, MessageType type, User author, DateTime timeStamp, DateTime? editedTimeStamp, string content, - IReadOnlyList attachments, IReadOnlyList mentionedUsers, - IReadOnlyList mentionedRoles, IReadOnlyList mentionedChannels) + IReadOnlyList attachments, IReadOnlyList embeds, + List mentionedUsers, List mentionedRoles, + List mentionedChannels) { Id = id; ChannelId = channelId; @@ -41,6 +44,7 @@ namespace DiscordChatExporter.Core.Models EditedTimeStamp = editedTimeStamp; Content = content; Attachments = attachments; + Embeds = embeds; MentionedUsers = mentionedUsers; MentionedRoles = mentionedRoles; MentionedChannels = mentionedChannels; diff --git a/DiscordChatExporter.Core/Models/User.cs b/DiscordChatExporter.Core/Models/User.cs index eec1579e..268631b2 100644 --- a/DiscordChatExporter.Core/Models/User.cs +++ b/DiscordChatExporter.Core/Models/User.cs @@ -2,7 +2,7 @@ namespace DiscordChatExporter.Core.Models { - public class User + public partial class User { public string Id { get; } @@ -33,4 +33,12 @@ namespace DiscordChatExporter.Core.Models return FullName; } } + + public partial class User + { + public static User CreateUnknownUser(string id) + { + return new User(id, 0, "Unknown", null); + } + } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportService/DarkTheme.css b/DiscordChatExporter.Core/Resources/ExportService/DarkTheme.css index e873b185..b6fc9847 100644 --- a/DiscordChatExporter.Core/Resources/ExportService/DarkTheme.css +++ b/DiscordChatExporter.Core/Resources/ExportService/DarkTheme.css @@ -1,142 +1,76 @@ body { background-color: #36393E; color: rgba(255, 255, 255, 0.7); - font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif; - font-size: 16px; } a { color: #0096CF; - text-decoration: none; -} - -a:hover { - text-decoration: underline; } div.pre { background-color: #2F3136; color: rgb(131, 148, 150); - font-family: Consolas, Courier New, Courier, Monospace; - margin-top: 4px; - padding: 8px; - white-space: pre-wrap; } span.pre { background-color: #2F3136; - font-family: Consolas, Courier New, Courier, Monospace; - padding-left: 2px; - padding-right: 2px; - white-space: pre-wrap; -} - -div#info { - display: flex; - margin-bottom: 10px; - margin-left: 5px; - margin-right: 5px; - max-width: 100%; -} - -div#log { - max-width: 100%; -} - -img.guild-icon { - max-height: 64px; - max-width: 64px; -} - -div.info-right { - flex: 1; - margin-left: 10px; } div.guild-name { color: #FFFFFF; - font-size: 1.4em; } div.channel-name { color: #FFFFFF; - font-size: 1.2em; } div.channel-topic { - margin-top: 2px; color: #FFFFFF; } -div.channel-messagecount { - margin-top: 2px; -} - div.msg { border-top: 1px solid rgba(255, 255, 255, 0.04); - display: flex; - margin-left: 10px; - margin-right: 10px; - padding-bottom: 15px; - padding-top: 15px; -} - -div.msg-left { - height: 40px; - width: 40px; -} - -img.msg-avatar { - border-radius: 50%; - height: 40px; - width: 40px; -} - -div.msg-right { - flex: 1; - margin-left: 20px; - min-width: 50%; } span.msg-user { color: #FFFFFF; - font-size: 1em; } span.msg-date { color: rgba(255, 255, 255, 0.2); - font-size: .75em; - margin-left: 5px; } span.msg-edited { color: rgba(255, 255, 255, 0.2); - font-size: .8em; - margin-left: 5px; } -div.msg-content { - font-size: .9375em; - padding-top: 5px; - word-wrap: break-word; +.embed-wrapper .embed-color-pill { + background-color: #4f545c } -div.msg-attachment { - margin-bottom: 5px; - margin-top: 5px; +.embed { + background-color: rgba(46, 48, 54, .3); + border-color: rgba(46, 48, 54, .6) } -img.msg-attachment { - max-height: 500px; - max-width: 50%; +.embed .embed-footer, +.embed .embed-provider { + color: hsla(0, 0%, 100%, .6) } -img.emoji { - height: 24px; - width: 24px; - vertical-align: -.4em; +.embed .embed-author-name { + color: #fff!important } -span.mention { - font-weight: 600; +.embed div.embed-title { + color: #fff +} + +.embed .embed-description, +.embed .embed-fields { + color: hsla(0, 0%, 100%, .6) +} + +.embed .embed-fields .embed-field-name { + color: #fff } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportService/LightTheme.css b/DiscordChatExporter.Core/Resources/ExportService/LightTheme.css index 3d2f81d7..abb25265 100644 --- a/DiscordChatExporter.Core/Resources/ExportService/LightTheme.css +++ b/DiscordChatExporter.Core/Resources/ExportService/LightTheme.css @@ -1,142 +1,45 @@ body { background-color: #FFFFFF; color: #737F8D; - font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif; - font-size: 16px; } a { color: #00B0F4; - text-decoration: none; -} - -a:hover { - text-decoration: underline; } div.pre { background-color: #F9F9F9; color: rgb(101, 123, 131); - font-family: Consolas, Courier New, Courier, Monospace; - margin-top: 4px; - padding: 8px; - white-space: pre-wrap; } span.pre { background-color: #F9F9F9; - font-family: Consolas, Courier New, Courier, Monospace; - padding-left: 2px; - padding-right: 2px; - white-space: pre-wrap; -} - -div#info { - display: flex; - margin-bottom: 10px; - margin-left: 5px; - margin-right: 5px; - max-width: 100%; -} - -div#log { - max-width: 100%; -} - -img.guild-icon { - max-height: 64px; - max-width: 64px; -} - -div.info-right { - flex: 1; - margin-left: 10px; } div.guild-name { color: #2F3136; - font-size: 1.4em; } div.channel-name { color: #2F3136; - font-size: 1.2em; } div.channel-topic { - margin-top: 2px; color: #2F3136; } -div.channel-messagecount { - margin-top: 2px; -} - div.msg { border-top: 1px solid #ECEEEF; - display: flex; - margin-left: 10px; - margin-right: 10px; - padding-bottom: 15px; - padding-top: 15px; -} - -div.msg-left { - height: 40px; - width: 40px; -} - -img.msg-avatar { - border-radius: 50%; - height: 40px; - width: 40px; -} - -div.msg-right { - flex: 1; - margin-left: 20px; - min-width: 50%; } span.msg-user { color: #2F3136; - font-size: 1em; } span.msg-date { color: #99AAB5; - font-size: .75em; - margin-left: 5px; } span.msg-edited { color: #99AAB5; - font-size: .8em; - margin-left: 5px; -} - -div.msg-content { - font-size: .9375em; - padding-top: 5px; - word-wrap: break-word; -} - -div.msg-attachment { - margin-bottom: 5px; - margin-top: 5px; -} - -img.msg-attachment { - max-height: 500px; - max-width: 50%; -} - -img.emoji { - height: 24px; - width: 24px; - vertical-align: -.4em; -} - -span.mention { - font-weight: 600; } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportService/Shared.css b/DiscordChatExporter.Core/Resources/ExportService/Shared.css new file mode 100644 index 00000000..ba41c3d1 --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportService/Shared.css @@ -0,0 +1,396 @@ +body { + font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif; + font-size: 16px; +} + +a { + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +div.pre { + font-family: Consolas, Courier New, Courier, Monospace; + margin-top: 4px; + padding: 8px; + white-space: pre-wrap; +} + +span.pre { + font-family: Consolas, Courier New, Courier, Monospace; + padding-left: 2px; + padding-right: 2px; + white-space: pre-wrap; +} + +div#info { + display: flex; + margin-bottom: 10px; + margin-left: 5px; + margin-right: 5px; + max-width: 100%; +} + +div#log { + max-width: 100%; +} + +img.guild-icon { + max-height: 64px; + max-width: 64px; +} + +div.info-right { + flex: 1; + margin-left: 10px; +} + +div.guild-name { + font-size: 1.4em; +} + +div.channel-name { + font-size: 1.2em; +} + +div.channel-topic { + margin-top: 2px; +} + +div.channel-messagecount { + margin-top: 2px; +} + +div.msg { + display: flex; + margin-left: 10px; + margin-right: 10px; + padding-bottom: 15px; + padding-top: 15px; +} + +div.msg-left { + height: 40px; + width: 40px; +} + +img.msg-avatar { + border-radius: 50%; + height: 40px; + width: 40px; +} + +div.msg-right { + flex: 1; + margin-left: 20px; + min-width: 50%; +} + +span.msg-user { + font-size: 1em; +} + +span.msg-date { + font-size: .75em; + margin-left: 5px; +} + +span.msg-edited { + font-size: .8em; + margin-left: 5px; +} + +div.msg-content { + font-size: .9375em; + padding-top: 5px; + word-wrap: break-word; +} + +div.msg-attachment { + margin-bottom: 5px; + margin-top: 5px; +} + +img.msg-attachment { + max-height: 500px; + max-width: 50%; +} + +span.mention { + font-weight: 600; + color: #7289da; + background-color: rgba(115, 139, 215, 0.1); +} + +.emoji { + -o-object-fit: contain; + object-fit: contain; + width: 24px; + height: 24px; + margin: 0 .05em 0 .1em!important; + vertical-align: -.4em +} + +.emoji.jumboable { + width: 32px; + height: 32px +} + +.image { + display: inline-block; + position: relative; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text +} + +.embed, +.embed-wrapper { + display: -webkit-box; + display: -ms-flexbox +} + +.embed-wrapper { + position: relative; + margin-top: 5px; + max-width: 520px; + display: flex +} + +.embed-wrapper .embed-color-pill { + width: 4px; + background: #cacbce; + border-radius: 3px 0 0 3px; + -ms-flex-negative: 0; + flex-shrink: 0 +} + +.embed { + padding: 8px 10px; + box-sizing: border-box; + background: hsla(0, 0%, 98%, .3); + border: 1px solid hsla(0, 0%, 80%, .3); + border-radius: 0 3px 3px 0; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column +} + +.embed .embed-content, +.embed.embed-rich { + display: -webkit-box; + display: -ms-flexbox +} + +.embed .embed-fields, +.embed.embed-link { + -webkit-box-orient: horizontal; + -webkit-box-direction: normal +} + +.embed div.embed-title { + color: #4f545c +} + +.embed .embed-content { + width: 100%; + display: flex; + margin-bottom: 10px +} + +.embed .embed-content .embed-content-inner { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1 +} + +.embed.embed-rich { + position: relative; + display: flex; + border-radius: 0 3px 3px 0 +} + +.embed.embed-rich .embed-rich-thumb { + max-height: 80px; + max-width: 80px; + border-radius: 3px; + width: auto; + -o-object-fit: contain; + object-fit: contain; + -ms-flex-negative: 0; + flex-shrink: 0; + margin-left: 20px +} + +.embed.embed-inline { + padding: 0; + margin: 4px 0; + border-radius: 3px +} + +.embed .image, +.embed video { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + overflow: hidden; + border-radius: 2px +} + +.embed .embed-content-inner>:last-child, +.embed .embed-content:last-child, +.embed .embed-inner>:last-child, +.embed>:last-child { + margin-bottom: 0!important +} + +.embed .embed-provider { + display: inline-block; + color: #87909c; + font-weight: 400; + font-size: 12px; + margin-bottom: 5px +} + +.embed .embed-author { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin-bottom: 5px +} + +.embed .embed-author-name, +.embed .embed-footer, +.embed .embed-title { + display: inline-block; + font-weight: 600 +} + +.embed .embed-author-name { + font-size: 14px; + color: #4f545c!important +} + +.embed .embed-author-icon { + margin-right: 9px; + width: 20px; + height: 20px; + -o-object-fit: contain; + object-fit: contain; + border-radius: 50% +} + +.embed .embed-footer { + font-size: 12px; + color: rgba(79, 83, 91, .6); + letter-spacing: 0 +} + +.embed .embed-footer-icon { + margin-right: 10px; + height: 18px; + width: 18px; + -o-object-fit: contain; + object-fit: contain; + float: left; + border-radius: 2.45px +} + +.embed .embed-title { + margin-bottom: 4px; + font-size: 14px +} + +.embed .embed-title+.embed-description { + margin-top: -3px!important +} + +.embed .embed-description { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 10px; + color: rgba(79, 83, 91, .9); + letter-spacing: 0 +} + +.embed .embed-description.markup { + white-space: pre-line; + margin-top: 0!important; + font-size: 14px!important; + line-height: 16px!important +} + +.embed .embed-description.markup pre { + max-width: 100%!important +} + +.embed .embed-fields { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -ms-flex-direction: row; + flex-direction: row; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + color: #36393e; + margin-top: -10px; + margin-bottom: 10px +} + +.embed .embed-fields .embed-field { + -webkit-box-flex: 0; + -ms-flex: 0; + flex: 0; + padding-top: 10px; + min-width: 100%; + max-width: 506px +} + +.embed .embed-fields .embed-field.embed-field-inline { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + min-width: 150px; + -ms-flex-preferred-size: auto; + flex-basis: auto +} + +.embed .embed-fields .embed-field .embed-field-name { + font-size: 14px; + margin-bottom: 4px; + font-weight: 600 +} + +.embed .embed-fields .embed-field .embed-field-value { + font-size: 14px; + font-weight: 500 +} + +.embed .embed-thumbnail, +.embed .embed-thumbnail-gifv { + position: relative; + display: inline-block +} + +.embed .embed-thumbnail { + margin-bottom: 10px +} + +.embed .embed-thumbnail img { + margin: 0; + max-width: 500px; + max-height: 400px; +} + +.comment>:last-child .embed { + margin-bottom: auto +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/DataService.cs b/DiscordChatExporter.Core/Services/DataService.cs index be0832b1..3b8b9dc2 100644 --- a/DiscordChatExporter.Core/Services/DataService.cs +++ b/DiscordChatExporter.Core/Services/DataService.cs @@ -8,6 +8,8 @@ using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Models; using Newtonsoft.Json.Linq; using Tyrrrz.Extensions; +using System.Drawing; +using System.Numerics; namespace DiscordChatExporter.Core.Services { @@ -16,6 +18,7 @@ namespace DiscordChatExporter.Core.Services private const string ApiRoot = "https://discordapp.com/api/v6"; private readonly HttpClient _httpClient = new HttpClient(); + private readonly Dictionary _userCache = new Dictionary(); private readonly Dictionary _roleCache = new Dictionary(); private readonly Dictionary _channelCache = new Dictionary(); @@ -76,6 +79,113 @@ namespace DiscordChatExporter.Core.Services return new Channel(id, guildId, name, topic, type); } + private Embed ParseEmbed(JToken token) + { + + // var embedFileSize = embedJson["size"].Value(); + var title = token["title"]?.Value(); + var type = token["type"]?.Value(); + var description = token["description"]?.Value(); + var url = token["url"]?.Value(); + var timestamp = token["timestamp"]?.Value(); + var color = token["color"] != null + ? Color.FromArgb(token["color"].Value()) + : (Color?)null; + + var footerNode = token["footer"]; + var footer = footerNode != null + ? new EmbedFooter( + footerNode["text"]?.Value(), + footerNode["icon_url"]?.Value(), + footerNode["proxy_icon_url"]?.Value()) + : null; + + var imageNode = token["image"]; + var image = imageNode != null + ? new EmbedImage( + imageNode["url"]?.Value(), + imageNode["proxy_url"]?.Value(), + imageNode["height"]?.Value(), + imageNode["width"]?.Value()) + : null; + + var thumbnailNode = token["thumbnail"]; + var thumbnail = thumbnailNode != null + ? new EmbedImage( + thumbnailNode["url"]?.Value(), + thumbnailNode["proxy_url"]?.Value(), + thumbnailNode["height"]?.Value(), + thumbnailNode["width"]?.Value()) + : null; + + var videoNode = token["video"]; + var video = videoNode != null + ? new EmbedVideo( + videoNode["url"]?.Value(), + videoNode["height"]?.Value(), + videoNode["width"]?.Value()) + : null; + + var providerNode = token["provider"]; + var provider = providerNode != null + ? new EmbedProvider( + providerNode["name"]?.Value(), + providerNode["url"]?.Value()) + : null; + + var authorNode = token["author"]; + var author = authorNode != null + ? new EmbedAuthor( + authorNode["name"]?.Value(), + authorNode["url"]?.Value(), + authorNode["icon_url"]?.Value(), + authorNode["proxy_icon_url"]?.Value()) + : null; + + var fields = new List(); + foreach (var fieldNode in token["fields"].EmptyIfNull()) + { + fields.Add(new EmbedField( + fieldNode["name"]?.Value(), + fieldNode["value"]?.Value(), + fieldNode["inline"]?.Value())); + } + + var mentionableContent = description ?? ""; + fields.ForEach(f => mentionableContent += f.Value); + + // Get user mentions + var mentionedUsers = Regex.Matches(mentionableContent, "<@!?(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .Select(i => _userCache.GetOrDefault(i) ?? User.CreateUnknownUser(i)) + .ToList(); + + // Get role mentions + var mentionedRoles = Regex.Matches(mentionableContent, "<@&(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(i)) + .ToList(); + + // Get channel mentions + var mentionedChannels = Regex.Matches(mentionableContent, "<#(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(i)) + .ToList(); + + return new Embed( + title, type, description, + url, timestamp, color, + footer, image, thumbnail, + video, provider, author, + fields, mentionedUsers, mentionedRoles, mentionedChannels); + } + private Message ParseMessage(JToken token) { // Get basic data @@ -123,27 +233,64 @@ namespace DiscordChatExporter.Core.Services attachments.Add(attachment); } + // Get embeds + var embeds = token["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray(); + // Get user mentions - var mentionedUsers = token["mentions"].Select(ParseUser).ToArray(); + var mentionedUsers = token["mentions"].Select(ParseUser).ToList(); // Get role mentions var mentionedRoles = token["mention_roles"] .Values() - .Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(id)) - .ToArray(); + .Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(i)) + .ToList(); // Get channel mentions var mentionedChannels = Regex.Matches(content, "<#(\\d+)>") .Cast() .Select(m => m.Groups[1].Value) .ExceptBlank() - .Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(id)) - .ToArray(); + .Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(i)) + .ToList(); - return new Message(id, channelId, type, author, timeStamp, editedTimeStamp, content, attachments, + return new Message(id, channelId, type, author, timeStamp, editedTimeStamp, content, attachments, embeds, mentionedUsers, mentionedRoles, mentionedChannels); } + /// + /// Attempts to query for users, channels, and roles if they havent been found yet, and set them in the mentionable + /// + private async Task FillMentionable(string token, string guildId, IMentionable mentionable) + { + for (int i = 0; i < mentionable.MentionedUsers.Count; i++) + { + var user = mentionable.MentionedUsers[i]; + if (user.Name == "Unknown" && user.Discriminator == 0) + { + try + { + mentionable.MentionedUsers[i] = _userCache.GetOrDefault(user.Id) ?? (await GetMemberAsync(token, guildId, user.Id)); + } + catch (HttpErrorStatusCodeException e) { } // This likely means the user doesnt exist any more, so ignore + } + } + + for (int i = 0; i < mentionable.MentionedChannels.Count; i++) + { + var channel = mentionable.MentionedChannels[i]; + if (channel.Name == "deleted-channel" && channel.GuildId == null) + { + try + { + mentionable.MentionedChannels[i] = _channelCache.GetOrDefault(channel.Id) ?? (await GetChannelAsync(token, channel.Id)); + } + catch (HttpErrorStatusCodeException e) { } // This likely means the user doesnt exist any more, so ignore + } + } + + // Roles are already gotten via GetGuildRolesAsync at the start + } + private async Task GetStringAsync(string url) { using (var response = await _httpClient.GetAsync(url)) @@ -193,6 +340,23 @@ namespace DiscordChatExporter.Core.Services return channel; } + public async Task GetMemberAsync(string token, string guildId, string memberId) + { + // Form request url + var url = $"{ApiRoot}/guilds/{guildId}/members/{memberId}?token={token}"; + + // Get response + var content = await GetStringAsync(url); + + // Parse + var user = ParseUser(JToken.Parse(content)["user"]); + + // Add user to cache + _userCache[user.Id] = user; + + return user; + } + public async Task> GetGuildChannelsAsync(string token, string guildId) { // Form request url @@ -211,6 +375,25 @@ namespace DiscordChatExporter.Core.Services return channels; } + + public async Task> GetGuildRolesAsync(string token, string guildId) + { + // Form request url + var url = $"{ApiRoot}/guilds/{guildId}/roles?token={token}"; + + // Get response + var content = await GetStringAsync(url); + + // Parse + var roles = JArray.Parse(content).Select(ParseRole).ToArray(); + + // Add roles to cache + foreach (var role in roles) + _roleCache[role.Id] = role; + + return roles; + } + public async Task> GetUserGuildsAsync(string token) { // Form request url @@ -247,9 +430,60 @@ namespace DiscordChatExporter.Core.Services return channels; } + public async Task> GetGuildMembersAsync(string token, string guildId) + { + var result = new List(); + + var afterId = ""; + while (true) + { + // Form request url + var url = $"{ApiRoot}/guilds/{guildId}/members?token={token}&limit=1000"; + if (afterId.IsNotBlank()) + url += $"&after={afterId}"; + + // Get response + var content = await GetStringAsync(url); + + // Parse + var users = JArray.Parse(content).Select(m => ParseUser(m["user"])); + + // Add user to cache + foreach (var user in users) + _userCache[user.Id] = user; + + // Add users to list + string currentUserId = null; + foreach (var user in users) + { + // Add user + result.Add(user); + if (currentUserId == null || BigInteger.Parse(user.Id) > BigInteger.Parse(currentUserId)) + currentUserId = user.Id; + } + + // If no users - break + if (currentUserId == null) + break; + + // Otherwise offset the next request + afterId = currentUserId; + } + + return result; + } + public async Task> GetChannelMessagesAsync(string token, string channelId, DateTime? from, DateTime? to) { + Channel channel = await GetChannelAsync(token, channelId); + + try + { + await GetGuildRolesAsync(token, channel.GuildId); + } + catch (HttpErrorStatusCodeException e) { } // This will be thrown if the user doesnt have the MANAGE_ROLES permission for the guild + var result = new List(); // We are going backwards from last message to first @@ -295,6 +529,13 @@ namespace DiscordChatExporter.Core.Services // Messages appear newest first, we need to reverse result.Reverse(); + foreach (var message in result) + { + await FillMentionable(token, channel.GuildId, message); + foreach (var embed in message.Embeds) + await FillMentionable(token, channel.GuildId, embed); + } + return result; } diff --git a/DiscordChatExporter.Core/Services/ExportService.Html.cs b/DiscordChatExporter.Core/Services/ExportService.Html.cs index 4913f11b..1b79a77f 100644 --- a/DiscordChatExporter.Core/Services/ExportService.Html.cs +++ b/DiscordChatExporter.Core/Services/ExportService.Html.cs @@ -3,17 +3,19 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using DiscordChatExporter.Core.Models; using Tyrrrz.Extensions; +using System.Drawing; +using System; +using System.Collections.Generic; +using System.Linq; namespace DiscordChatExporter.Core.Services { public partial class ExportService { - private string FormatMessageContentHtml(Message message) + private string MarkdownToHtml(string content, IMentionable mentionable = null, bool allowLinks = false) { // A lot of these regexes were inspired by or taken from MarkdownSharp - var content = message.Content; - // HTML-encode content content = HtmlEncode(content); @@ -29,7 +31,7 @@ namespace DiscordChatExporter.Core.Services // Encode URLs content = Regex.Replace(content, - @"((https?|ftp)://[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\]\(\);]*[-a-zA-Z0-9+&@#/%=~_|\[\])])(?=$|\W)", + @"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))", m => $"\x1AL{Base64Encode(m.Groups[1].Value)}\x1AL"); // Process bold (**text**) @@ -52,6 +54,12 @@ namespace DiscordChatExporter.Core.Services content = Regex.Replace(content, "\x1AI(.*?)\x1AI", m => $"{Base64Decode(m.Groups[1].Value)}"); + if (allowLinks) + { + content = Regex.Replace(content, "\\[([^\\]]+)\\]\\(\x1AL(.*?)\x1AL\\)", + m => $"{m.Groups[1].Value}"); + } + // Decode and process URLs content = Regex.Replace(content, "\x1AL(.*?)\x1AL", m => $"{Base64Decode(m.Groups[1].Value)}"); @@ -65,31 +73,34 @@ namespace DiscordChatExporter.Core.Services // Meta mentions (@here) content = content.Replace("@here", "@here"); - // User mentions (<@id> and <@!id>) - foreach (var mentionedUser in message.MentionedUsers) + if (mentionable != null) { - content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", - $"" + - $"@{HtmlEncode(mentionedUser.Name)}" + - ""); - } + // User mentions (<@id> and <@!id>) + foreach (var mentionedUser in mentionable.MentionedUsers) + { + content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", + $"" + + $"@{HtmlEncode(mentionedUser.Name)}" + + ""); + } - // Role mentions (<@&id>) - foreach (var mentionedRole in message.MentionedRoles) - { - content = content.Replace($"<@&{mentionedRole.Id}>", - "" + - $"@{HtmlEncode(mentionedRole.Name)}" + - ""); - } + // Role mentions (<@&id>) + foreach (var mentionedRole in mentionable.MentionedRoles) + { + content = content.Replace($"<@&{mentionedRole.Id}>", + "" + + $"@{HtmlEncode(mentionedRole.Name)}" + + ""); + } - // Channel mentions (<#id>) - foreach (var mentionedChannel in message.MentionedChannels) - { - content = content.Replace($"<#{mentionedChannel.Id}>", - "" + - $"#{HtmlEncode(mentionedChannel.Name)}" + - ""); + // Channel mentions (<#id>) + foreach (var mentionedChannel in mentionable.MentionedChannels) + { + content = content.Replace($"<#{mentionedChannel.Id}>", + "" + + $"#{HtmlEncode(mentionedChannel.Name)}" + + ""); + } } // Custom emojis (<:name:id>) @@ -99,6 +110,145 @@ namespace DiscordChatExporter.Core.Services return content; } + private string FormatMessageContentHtml(Message message) + { + return MarkdownToHtml(message.Content, message); + } + + // The code used to convert embeds to html was based heavily off of the Embed Visualizer project, from this file: + // https://github.com/leovoel/embed-visualizer/blob/master/src/components/embed.jsx + + private string EmbedColorPillToHtml(Color? color) + { + string backgroundColor = ""; + + if (color != null) + backgroundColor = $"rgba({color?.R},{color?.G},{color?.B},1)"; + + return $"
"; + } + + private string EmbedTitleToHtml(string title, string url) + { + if (title == null) + return null; + + string computed = $"
{MarkdownToHtml(title)}
"; + if (url != null) + computed = $"{MarkdownToHtml(title)}"; + + return computed; + } + + private string EmbedDescriptionToHtml(string content, IMentionable mentionable) + { + if (content == null) + return null; + + return $"
{MarkdownToHtml(content, mentionable, true)}
"; + } + + private string EmbedAuthorToHtml(string name, string url, string icon_url) + { + if (name == null) + return null; + + string authorName = null; + if (name != null) + { + authorName = $"{name}"; + if (url != null) + authorName = $"{name}"; + } + + string authorIcon = icon_url != null ? $"" : null; + + return $"
{authorIcon}{authorName}
"; + } + + private string EmbedFieldToHtml(string name, string value, bool? inline, IMentionable mentionable) + { + if (name == null && value == null) + return null; + + string cls = "embed-field" + (inline == true ? " embed-field-inline" : ""); + + string fieldName = name != null ? $"
{MarkdownToHtml(name)}
" : null; + string fieldValue = value != null ? $"
{MarkdownToHtml(value, mentionable, true)}
" : null; + + return $"
{fieldName}{fieldValue}
"; + } + + private string EmbedThumbnailToHtml(string url) + { + if (url == null) + return null; + + return $@" + "; + } + + private string EmbedImageToHtml(string url) + { + if (url == null) + return null; + + return $""; + } + + private string EmbedFooterToHtml(DateTime? timestamp, string text, string icon_url) + { + if (text == null && timestamp == null) + return null; + + // format: ddd MMM Do, YYYY [at] h:mm A + + string time = timestamp != null ? HtmlEncode(timestamp?.ToString(_settingsService.DateFormat)) : null; + + string footerText = string.Join(" | ", new List { text, time }.Where(s => s != null)); + string footerIcon = text != null && icon_url != null + ? $"" + : null; + + return $"
{footerIcon}{footerText}
"; + } + + private string EmbedFieldsToHtml(IReadOnlyList fields, IMentionable mentionable) + { + if (fields.Count == 0) + return null; + + return $"
{string.Join("", fields.Select(f => EmbedFieldToHtml(f.Name, f.Value, f.Inline, mentionable)))}
"; + } + + private string FormatEmbedHtml(Embed embed) + { + return $@" +
+
+ {EmbedColorPillToHtml(embed.Color)} +
+
+
+ {EmbedAuthorToHtml(embed.Author?.Name, embed.Author?.Url, embed.Author?.IconUrl)} + {EmbedTitleToHtml(embed.Title, embed.Url)} + {EmbedDescriptionToHtml(embed.Description, embed)} + {EmbedFieldsToHtml(embed.Fields, embed)} +
+ {EmbedThumbnailToHtml(embed.Thumbnail?.Url)} +
+ {EmbedImageToHtml(embed.Image?.Url)} + {EmbedFooterToHtml(embed.TimeStamp, embed.Footer?.Text, embed.Footer?.IconUrl)} +
+
+
"; + } + private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css) { // Generation info @@ -193,6 +343,13 @@ namespace DiscordChatExporter.Core.Services await output.WriteLineAsync(""); } } + + // Embeds + foreach (var embed in message.Embeds) + { + var contentFormatted = FormatEmbedHtml(embed); + await output.WriteAsync(contentFormatted); + } } await output.WriteLineAsync(""); // msg-right diff --git a/DiscordChatExporter.Core/Services/ExportService.cs b/DiscordChatExporter.Core/Services/ExportService.cs index fe6b4ae4..945ce276 100644 --- a/DiscordChatExporter.Core/Services/ExportService.cs +++ b/DiscordChatExporter.Core/Services/ExportService.cs @@ -22,25 +22,25 @@ namespace DiscordChatExporter.Core.Services { using (var output = File.CreateText(filePath)) { + var sharedCss = Assembly.GetExecutingAssembly() + .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.Shared.css"); + if (format == ExportFormat.PlainText) { await ExportAsPlainTextAsync(log, output); } - else if (format == ExportFormat.HtmlDark) { var css = Assembly.GetExecutingAssembly() .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css"); - await ExportAsHtmlAsync(log, output, css); + await ExportAsHtmlAsync(log, output, $"{sharedCss}\n{css}"); } - else if (format == ExportFormat.HtmlLight) { var css = Assembly.GetExecutingAssembly() .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css"); - await ExportAsHtmlAsync(log, output, css); + await ExportAsHtmlAsync(log, output, $"{sharedCss}\n{css}"); } - else if (format == ExportFormat.Csv) { await ExportAsCsvAsync(log, output);