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>
</PropertyGroup>
<ItemGroup>
<None Remove="Resources\ExportService\Shared.css" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportService\Shared.css" />
</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
{
public class Message
public class Message : IMentionable
{
public string Id { get; }
@ -21,17 +21,20 @@ namespace DiscordChatExporter.Core.Models
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,
User author, DateTime timeStamp,
DateTime? editedTimeStamp, string content,
IReadOnlyList<Attachment> attachments, IReadOnlyList<User> mentionedUsers,
IReadOnlyList<Role> mentionedRoles, IReadOnlyList<Channel> mentionedChannels)
IReadOnlyList<Attachment> attachments, IReadOnlyList<Embed> embeds,
List<User> mentionedUsers, List<Role> mentionedRoles,
List<Channel> 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;

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

@ -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
}
.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 {
margin-bottom: 5px;
margin-top: 5px;
.embed .embed-author-name {
color: #fff!important
}
img.msg-attachment {
max-height: 500px;
max-width: 50%;
.embed div.embed-title {
color: #fff
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
.embed .embed-description,
.embed .embed-fields {
color: hsla(0, 0%, 100%, .6)
}
span.mention {
font-weight: 600;
.embed .embed-fields .embed-field-name {
color: #fff
}

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

@ -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 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<string, User> _userCache = new Dictionary<string, User>();
private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>();
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);
}
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)
{
// 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<string>()
.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<Match>()
.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);
}
/// <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)
{
using (var response = await _httpClient.GetAsync(url))
@ -193,6 +340,23 @@ namespace DiscordChatExporter.Core.Services
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)
{
// Form request url
@ -211,6 +375,25 @@ namespace DiscordChatExporter.Core.Services
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)
{
// Form request url
@ -247,9 +430,60 @@ namespace DiscordChatExporter.Core.Services
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,
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>();
// 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;
}

@ -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 => $"<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
content = Regex.Replace(content, "\x1AL(.*?)\x1AL",
m => $"<a href=\"{Base64Decode(m.Groups[1].Value)}\">{Base64Decode(m.Groups[1].Value)}</a>");
@ -65,31 +73,34 @@ namespace DiscordChatExporter.Core.Services
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
if (mentionable != null)
{
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in mentionable.MentionedUsers)
{
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
{
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
foreach (var mentionedRole in mentionable.MentionedRoles)
{
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
{
content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
// Channel mentions (<#id>)
foreach (var mentionedChannel in mentionable.MentionedChannels)
{
content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
}
// 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 $"<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)
{
// Generation info
@ -193,6 +343,13 @@ namespace DiscordChatExporter.Core.Services
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

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

Loading…
Cancel
Save