Add support for embeds (#46)

pull/57/head
Malcolm Diller 7 years ago committed by Alexey Golub
parent 3b7da21c24
commit d958f613a3

@ -5,9 +5,14 @@
<Version>2.4.1</Version> <Version>2.4.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<None Remove="Resources\ExportService\Shared.css" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" /> <EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" /> <EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportService\Shared.css" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -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<EmbedField> Fields { get; }
public List<User> MentionedUsers { get; }
public List<Role> MentionedRoles { get; }
public List<Channel> 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<EmbedField> fields, List<User> mentionedUsers,
List<Role> mentionedRoles, List<Channel> 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;
}
}
}

@ -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;
}
}
}

@ -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;
}
}
}

@ -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;
}
}
}

@ -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;
}
}
}

@ -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;
}
}
}

@ -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;
}
}
}

@ -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<User> MentionedUsers { get; }
List<Role> MentionedRoles { get; }
List<Channel> MentionedChannels { get; }
}
}

@ -3,7 +3,7 @@ using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
public class Message public class Message : IMentionable
{ {
public string Id { get; } public string Id { get; }
@ -21,17 +21,20 @@ namespace DiscordChatExporter.Core.Models
public IReadOnlyList<Attachment> Attachments { get; } public IReadOnlyList<Attachment> Attachments { get; }
public IReadOnlyList<User> MentionedUsers { get; } public IReadOnlyList<Embed> Embeds { get; }
public IReadOnlyList<Role> MentionedRoles { get; } public List<User> MentionedUsers { get; }
public IReadOnlyList<Channel> MentionedChannels { get; } public List<Role> MentionedRoles { get; }
public List<Channel> MentionedChannels { get; }
public Message(string id, string channelId, MessageType type, public Message(string id, string channelId, MessageType type,
User author, DateTime timeStamp, User author, DateTime timeStamp,
DateTime? editedTimeStamp, string content, DateTime? editedTimeStamp, string content,
IReadOnlyList<Attachment> attachments, IReadOnlyList<User> mentionedUsers, IReadOnlyList<Attachment> attachments, IReadOnlyList<Embed> embeds,
IReadOnlyList<Role> mentionedRoles, IReadOnlyList<Channel> mentionedChannels) List<User> mentionedUsers, List<Role> mentionedRoles,
List<Channel> mentionedChannels)
{ {
Id = id; Id = id;
ChannelId = channelId; ChannelId = channelId;
@ -41,6 +44,7 @@ namespace DiscordChatExporter.Core.Models
EditedTimeStamp = editedTimeStamp; EditedTimeStamp = editedTimeStamp;
Content = content; Content = content;
Attachments = attachments; Attachments = attachments;
Embeds = embeds;
MentionedUsers = mentionedUsers; MentionedUsers = mentionedUsers;
MentionedRoles = mentionedRoles; MentionedRoles = mentionedRoles;
MentionedChannels = mentionedChannels; MentionedChannels = mentionedChannels;

@ -2,7 +2,7 @@
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
public class User public partial class User
{ {
public string Id { get; } public string Id { get; }
@ -33,4 +33,12 @@ namespace DiscordChatExporter.Core.Models
return FullName; return FullName;
} }
} }
public partial class User
{
public static User CreateUnknownUser(string id)
{
return new User(id, 0, "Unknown", null);
}
}
} }

@ -1,142 +1,76 @@
body { body {
background-color: #36393E; background-color: #36393E;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
} }
a { a {
color: #0096CF; color: #0096CF;
text-decoration: none;
}
a:hover {
text-decoration: underline;
} }
div.pre { div.pre {
background-color: #2F3136; background-color: #2F3136;
color: rgb(131, 148, 150); color: rgb(131, 148, 150);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
} }
span.pre { span.pre {
background-color: #2F3136; 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 { div.guild-name {
color: #FFFFFF; color: #FFFFFF;
font-size: 1.4em;
} }
div.channel-name { div.channel-name {
color: #FFFFFF; color: #FFFFFF;
font-size: 1.2em;
} }
div.channel-topic { div.channel-topic {
margin-top: 2px;
color: #FFFFFF; color: #FFFFFF;
} }
div.channel-messagecount {
margin-top: 2px;
}
div.msg { div.msg {
border-top: 1px solid rgba(255, 255, 255, 0.04); 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 { span.msg-user {
color: #FFFFFF; color: #FFFFFF;
font-size: 1em;
} }
span.msg-date { span.msg-date {
color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.2);
font-size: .75em;
margin-left: 5px;
} }
span.msg-edited { span.msg-edited {
color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.2);
font-size: .8em;
margin-left: 5px;
} }
div.msg-content { .embed-wrapper .embed-color-pill {
font-size: .9375em; background-color: #4f545c
padding-top: 5px; }
word-wrap: break-word;
.embed {
background-color: rgba(46, 48, 54, .3);
border-color: rgba(46, 48, 54, .6)
}
.embed .embed-footer,
.embed .embed-provider {
color: hsla(0, 0%, 100%, .6)
} }
div.msg-attachment { .embed .embed-author-name {
margin-bottom: 5px; color: #fff!important
margin-top: 5px;
} }
img.msg-attachment { .embed div.embed-title {
max-height: 500px; color: #fff
max-width: 50%;
} }
img.emoji { .embed .embed-description,
height: 24px; .embed .embed-fields {
width: 24px; color: hsla(0, 0%, 100%, .6)
vertical-align: -.4em;
} }
span.mention { .embed .embed-fields .embed-field-name {
font-weight: 600; color: #fff
} }

@ -1,142 +1,45 @@
body { body {
background-color: #FFFFFF; background-color: #FFFFFF;
color: #737F8D; color: #737F8D;
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
} }
a { a {
color: #00B0F4; color: #00B0F4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
} }
div.pre { div.pre {
background-color: #F9F9F9; background-color: #F9F9F9;
color: rgb(101, 123, 131); color: rgb(101, 123, 131);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
} }
span.pre { span.pre {
background-color: #F9F9F9; 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 { div.guild-name {
color: #2F3136; color: #2F3136;
font-size: 1.4em;
} }
div.channel-name { div.channel-name {
color: #2F3136; color: #2F3136;
font-size: 1.2em;
} }
div.channel-topic { div.channel-topic {
margin-top: 2px;
color: #2F3136; color: #2F3136;
} }
div.channel-messagecount {
margin-top: 2px;
}
div.msg { div.msg {
border-top: 1px solid #ECEEEF; 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 { span.msg-user {
color: #2F3136; color: #2F3136;
font-size: 1em;
} }
span.msg-date { span.msg-date {
color: #99AAB5; color: #99AAB5;
font-size: .75em;
margin-left: 5px;
} }
span.msg-edited { span.msg-edited {
color: #99AAB5; 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;
} }

@ -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
}

@ -8,6 +8,8 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
using System.Drawing;
using System.Numerics;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
@ -16,6 +18,7 @@ namespace DiscordChatExporter.Core.Services
private const string ApiRoot = "https://discordapp.com/api/v6"; private const string ApiRoot = "https://discordapp.com/api/v6";
private readonly HttpClient _httpClient = new HttpClient(); private readonly HttpClient _httpClient = new HttpClient();
private readonly Dictionary<string, User> _userCache = new Dictionary<string, User>();
private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>(); private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>();
private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>(); private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>();
@ -76,6 +79,113 @@ namespace DiscordChatExporter.Core.Services
return new Channel(id, guildId, name, topic, type); return new Channel(id, guildId, name, topic, type);
} }
private Embed ParseEmbed(JToken token)
{
// var embedFileSize = embedJson["size"].Value<long>();
var title = token["title"]?.Value<string>();
var type = token["type"]?.Value<string>();
var description = token["description"]?.Value<string>();
var url = token["url"]?.Value<string>();
var timestamp = token["timestamp"]?.Value<DateTime>();
var color = token["color"] != null
? Color.FromArgb(token["color"].Value<int>())
: (Color?)null;
var footerNode = token["footer"];
var footer = footerNode != null
? new EmbedFooter(
footerNode["text"]?.Value<string>(),
footerNode["icon_url"]?.Value<string>(),
footerNode["proxy_icon_url"]?.Value<string>())
: null;
var imageNode = token["image"];
var image = imageNode != null
? new EmbedImage(
imageNode["url"]?.Value<string>(),
imageNode["proxy_url"]?.Value<string>(),
imageNode["height"]?.Value<int>(),
imageNode["width"]?.Value<int>())
: null;
var thumbnailNode = token["thumbnail"];
var thumbnail = thumbnailNode != null
? new EmbedImage(
thumbnailNode["url"]?.Value<string>(),
thumbnailNode["proxy_url"]?.Value<string>(),
thumbnailNode["height"]?.Value<int>(),
thumbnailNode["width"]?.Value<int>())
: null;
var videoNode = token["video"];
var video = videoNode != null
? new EmbedVideo(
videoNode["url"]?.Value<string>(),
videoNode["height"]?.Value<int>(),
videoNode["width"]?.Value<int>())
: null;
var providerNode = token["provider"];
var provider = providerNode != null
? new EmbedProvider(
providerNode["name"]?.Value<string>(),
providerNode["url"]?.Value<string>())
: null;
var authorNode = token["author"];
var author = authorNode != null
? new EmbedAuthor(
authorNode["name"]?.Value<string>(),
authorNode["url"]?.Value<string>(),
authorNode["icon_url"]?.Value<string>(),
authorNode["proxy_icon_url"]?.Value<string>())
: null;
var fields = new List<EmbedField>();
foreach (var fieldNode in token["fields"].EmptyIfNull())
{
fields.Add(new EmbedField(
fieldNode["name"]?.Value<string>(),
fieldNode["value"]?.Value<string>(),
fieldNode["inline"]?.Value<bool>()));
}
var mentionableContent = description ?? "";
fields.ForEach(f => mentionableContent += f.Value);
// Get user mentions
var mentionedUsers = Regex.Matches(mentionableContent, "<@!?(\\d+)>")
.Cast<Match>()
.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<Match>()
.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<Match>()
.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) private Message ParseMessage(JToken token)
{ {
// Get basic data // Get basic data
@ -123,27 +233,64 @@ namespace DiscordChatExporter.Core.Services
attachments.Add(attachment); attachments.Add(attachment);
} }
// Get embeds
var embeds = token["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray();
// Get user mentions // Get user mentions
var mentionedUsers = token["mentions"].Select(ParseUser).ToArray(); var mentionedUsers = token["mentions"].Select(ParseUser).ToList();
// Get role mentions // Get role mentions
var mentionedRoles = token["mention_roles"] var mentionedRoles = token["mention_roles"]
.Values<string>() .Values<string>()
.Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(id)) .Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(i))
.ToArray(); .ToList();
// Get channel mentions // Get channel mentions
var mentionedChannels = Regex.Matches(content, "<#(\\d+)>") var mentionedChannels = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>() .Cast<Match>()
.Select(m => m.Groups[1].Value) .Select(m => m.Groups[1].Value)
.ExceptBlank() .ExceptBlank()
.Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(id)) .Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(i))
.ToArray(); .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); mentionedUsers, mentionedRoles, mentionedChannels);
} }
/// <summary>
/// Attempts to query for users, channels, and roles if they havent been found yet, and set them in the mentionable
/// </summary>
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<string> GetStringAsync(string url) private async Task<string> GetStringAsync(string url)
{ {
using (var response = await _httpClient.GetAsync(url)) using (var response = await _httpClient.GetAsync(url))
@ -193,6 +340,23 @@ namespace DiscordChatExporter.Core.Services
return channel; return channel;
} }
public async Task<User> 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<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId) public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
{ {
// Form request url // Form request url
@ -211,6 +375,25 @@ namespace DiscordChatExporter.Core.Services
return channels; return channels;
} }
public async Task<IReadOnlyList<Role>> 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<IReadOnlyList<Guild>> GetUserGuildsAsync(string token) public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token)
{ {
// Form request url // Form request url
@ -247,9 +430,60 @@ namespace DiscordChatExporter.Core.Services
return channels; return channels;
} }
public async Task<IReadOnlyList<User>> GetGuildMembersAsync(string token, string guildId)
{
var result = new List<User>();
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<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId, public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to) 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<Message>(); var result = new List<Message>();
// We are going backwards from last message to first // 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 // Messages appear newest first, we need to reverse
result.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; return result;
} }

@ -3,17 +3,19 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
using System.Drawing;
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
public partial class ExportService 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 // A lot of these regexes were inspired by or taken from MarkdownSharp
var content = message.Content;
// HTML-encode content // HTML-encode content
content = HtmlEncode(content); content = HtmlEncode(content);
@ -29,7 +31,7 @@ namespace DiscordChatExporter.Core.Services
// Encode URLs // Encode URLs
content = Regex.Replace(content, 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"); m => $"\x1AL{Base64Encode(m.Groups[1].Value)}\x1AL");
// Process bold (**text**) // Process bold (**text**)
@ -52,6 +54,12 @@ namespace DiscordChatExporter.Core.Services
content = Regex.Replace(content, "\x1AI(.*?)\x1AI", content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre\">{Base64Decode(m.Groups[1].Value)}</span>"); m => $"<span class=\"pre\">{Base64Decode(m.Groups[1].Value)}</span>");
if (allowLinks)
{
content = Regex.Replace(content, "\\[([^\\]]+)\\]\\(\x1AL(.*?)\x1AL\\)",
m => $"<a href=\"{Base64Decode(m.Groups[2].Value)}\">{m.Groups[1].Value}</a>");
}
// Decode and process URLs // Decode and process URLs
content = Regex.Replace(content, "\x1AL(.*?)\x1AL", content = Regex.Replace(content, "\x1AL(.*?)\x1AL",
m => $"<a href=\"{Base64Decode(m.Groups[1].Value)}\">{Base64Decode(m.Groups[1].Value)}</a>"); m => $"<a href=\"{Base64Decode(m.Groups[1].Value)}\">{Base64Decode(m.Groups[1].Value)}</a>");
@ -65,8 +73,10 @@ namespace DiscordChatExporter.Core.Services
// Meta mentions (@here) // Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>"); content = content.Replace("@here", "<span class=\"mention\">@here</span>");
if (mentionable != null)
{
// User mentions (<@id> and <@!id>) // User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers) foreach (var mentionedUser in mentionable.MentionedUsers)
{ {
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;", content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" + $"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
@ -75,7 +85,7 @@ namespace DiscordChatExporter.Core.Services
} }
// Role mentions (<@&id>) // Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles) foreach (var mentionedRole in mentionable.MentionedRoles)
{ {
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;", content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" + "<span class=\"mention\">" +
@ -84,13 +94,14 @@ namespace DiscordChatExporter.Core.Services
} }
// Channel mentions (<#id>) // Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels) foreach (var mentionedChannel in mentionable.MentionedChannels)
{ {
content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;", content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;",
"<span class=\"mention\">" + "<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" + $"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>"); "</span>");
} }
}
// Custom emojis (<:name:id>) // Custom emojis (<:name:id>)
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;", content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
@ -99,6 +110,145 @@ namespace DiscordChatExporter.Core.Services
return content; 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 $"<div class='embed-color-pill' style='background-color: {backgroundColor}'></div>";
}
private string EmbedTitleToHtml(string title, string url)
{
if (title == null)
return null;
string computed = $"<div class='embed-title'>{MarkdownToHtml(title)}</div>";
if (url != null)
computed = $"<a target='_blank' rel='noreferrer' href='{url}' class='embed-title'>{MarkdownToHtml(title)}</a>";
return computed;
}
private string EmbedDescriptionToHtml(string content, IMentionable mentionable)
{
if (content == null)
return null;
return $"<div class='embed-description markup'>{MarkdownToHtml(content, mentionable, true)}</div>";
}
private string EmbedAuthorToHtml(string name, string url, string icon_url)
{
if (name == null)
return null;
string authorName = null;
if (name != null)
{
authorName = $"<span class='embed-author-name'>{name}</span>";
if (url != null)
authorName = $"<a target='_blank' rel='noreferrer' href='{url}' class='embed-author-name'>{name}</a>";
}
string authorIcon = icon_url != null ? $"<img src='{icon_url}' role='presentation' class='embed-author-icon' />" : null;
return $"<div class='embed-author'>{authorIcon}{authorName}</div>";
}
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 ? $"<div class='embed-field-name'>{MarkdownToHtml(name)}</div>" : null;
string fieldValue = value != null ? $"<div class='embed-field-value markup'>{MarkdownToHtml(value, mentionable, true)}</div>" : null;
return $"<div class='{cls}'>{fieldName}{fieldValue}</div>";
}
private string EmbedThumbnailToHtml(string url)
{
if (url == null)
return null;
return $@"
<img
src = '{url}'
role = 'presentation'
class='embed-rich-thumb'
style='max-width: 80px; max-height: 80px'
/>";
}
private string EmbedImageToHtml(string url)
{
if (url == null)
return null;
return $"<a class='embed-thumbnail embed-thumbnail-rich'><img class='image' role='presentation' src='{url}' /></a>";
}
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<string> { text, time }.Where(s => s != null));
string footerIcon = text != null && icon_url != null
? $"<img src='{icon_url}' class='embed-footer-icon' role='presentation' width='20' height='20' />"
: null;
return $"<div>{footerIcon}<span class='embed-footer'>{footerText}</span></div>";
}
private string EmbedFieldsToHtml(IReadOnlyList<EmbedField> fields, IMentionable mentionable)
{
if (fields.Count == 0)
return null;
return $"<div class='embed-fields'>{string.Join("", fields.Select(f => EmbedFieldToHtml(f.Name, f.Value, f.Inline, mentionable)))}</div>";
}
private string FormatEmbedHtml(Embed embed)
{
return $@"
<div class='accessory'>
<div class='embed-wrapper'>
{EmbedColorPillToHtml(embed.Color)}
<div class='embed embed-rich'>
<div class='embed-content'>
<div class='embed-content-inner'>
{EmbedAuthorToHtml(embed.Author?.Name, embed.Author?.Url, embed.Author?.IconUrl)}
{EmbedTitleToHtml(embed.Title, embed.Url)}
{EmbedDescriptionToHtml(embed.Description, embed)}
{EmbedFieldsToHtml(embed.Fields, embed)}
</div>
{EmbedThumbnailToHtml(embed.Thumbnail?.Url)}
</div>
{EmbedImageToHtml(embed.Image?.Url)}
{EmbedFooterToHtml(embed.TimeStamp, embed.Footer?.Text, embed.Footer?.IconUrl)}
</div>
</div>
</div>";
}
private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css) private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css)
{ {
// Generation info // Generation info
@ -193,6 +343,13 @@ namespace DiscordChatExporter.Core.Services
await output.WriteLineAsync("</div>"); await output.WriteLineAsync("</div>");
} }
} }
// Embeds
foreach (var embed in message.Embeds)
{
var contentFormatted = FormatEmbedHtml(embed);
await output.WriteAsync(contentFormatted);
}
} }
await output.WriteLineAsync("</div>"); // msg-right await output.WriteLineAsync("</div>"); // msg-right

@ -22,25 +22,25 @@ namespace DiscordChatExporter.Core.Services
{ {
using (var output = File.CreateText(filePath)) using (var output = File.CreateText(filePath))
{ {
var sharedCss = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.Shared.css");
if (format == ExportFormat.PlainText) if (format == ExportFormat.PlainText)
{ {
await ExportAsPlainTextAsync(log, output); await ExportAsPlainTextAsync(log, output);
} }
else if (format == ExportFormat.HtmlDark) else if (format == ExportFormat.HtmlDark)
{ {
var css = Assembly.GetExecutingAssembly() var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css"); .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css");
await ExportAsHtmlAsync(log, output, css); await ExportAsHtmlAsync(log, output, $"{sharedCss}\n{css}");
} }
else if (format == ExportFormat.HtmlLight) else if (format == ExportFormat.HtmlLight)
{ {
var css = Assembly.GetExecutingAssembly() var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css"); .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css");
await ExportAsHtmlAsync(log, output, css); await ExportAsHtmlAsync(log, output, $"{sharedCss}\n{css}");
} }
else if (format == ExportFormat.Csv) else if (format == ExportFormat.Csv)
{ {
await ExportAsCsvAsync(log, output); await ExportAsCsvAsync(log, output);

Loading…
Cancel
Save