Architecture refactor (#63)

This commit is contained in:
Alexey Golub
2018-06-25 01:59:15 +03:00
committed by GitHub
parent d958f613a3
commit 481991bd00
52 changed files with 1484 additions and 1846 deletions

View File

@@ -0,0 +1,183 @@
using System;
using System.Drawing;
using System.Linq;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService
{
private User ParseUser(JToken json)
{
var id = json["id"].Value<string>();
var discriminator = json["discriminator"].Value<int>();
var name = json["username"].Value<string>();
var avatarHash = json["avatar"].Value<string>();
return new User(id, discriminator, name, avatarHash);
}
private Guild ParseGuild(JToken json)
{
var id = json["id"].Value<string>();
var name = json["name"].Value<string>();
var iconHash = json["icon"].Value<string>();
return new Guild(id, name, iconHash);
}
private Channel ParseChannel(JToken json)
{
// Get basic data
var id = json["id"].Value<string>();
var type = (ChannelType) json["type"].Value<int>();
var topic = json["topic"]?.Value<string>();
// Try to extract guild ID
var guildId = json["guild_id"]?.Value<string>();
// If the guild ID is blank, it's direct messages
if (guildId.IsBlank())
guildId = Guild.DirectMessages.Id;
// Try to extract name
var name = json["name"]?.Value<string>();
// If the name is blank, it's direct messages
if (name.IsBlank())
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
return new Channel(id, guildId, name, topic, type);
}
private Role ParseRole(JToken json)
{
var id = json["id"].Value<string>();
var name = json["name"].Value<string>();
return new Role(id, name);
}
private EmbedAuthor ParseEmbedAuthor(JToken json)
{
var name = json["name"]?.Value<string>();
var url = json["url"]?.Value<string>();
var iconUrl = json["icon_url"]?.Value<string>();
return new EmbedAuthor(name, url, iconUrl);
}
private EmbedField ParseEmbedField(JToken json)
{
var name = json["name"].Value<string>();
var value = json["value"].Value<string>();
var isInline = json["inline"]?.Value<bool>() ?? false;
return new EmbedField(name, value, isInline);
}
private Attachment ParseAttachment(JToken json)
{
var id = json["id"].Value<string>();
var url = json["url"].Value<string>();
var isImage = json["width"] != null;
var fileName = json["filename"].Value<string>();
var fileSize = json["size"].Value<long>();
return new Attachment(id, isImage, url, fileName, fileSize);
}
private EmbedImage ParseEmbedImage(JToken json)
{
var url = json["url"]?.Value<string>();
var height = json["height"]?.Value<int>();
var width = json["width"]?.Value<int>();
return new EmbedImage(url, height, width);
}
private EmbedFooter ParseEmbedFooter(JToken json)
{
var text = json["text"].Value<string>();
var iconUrl = json["icon_url"]?.Value<string>();
return new EmbedFooter(text, iconUrl);
}
private Embed ParseEmbed(JToken json)
{
// Get basic data
var title = json["title"]?.Value<string>();
var description = json["description"]?.Value<string>();
var url = json["url"]?.Value<string>();
var timestamp = json["timestamp"]?.Value<DateTime>();
// Get color
var color = json["color"] != null
? Color.FromArgb(json["color"].Value<int>()).ResetAlpha()
: Color.FromArgb(79, 84, 92); // default color
// Get author
var author = json["author"] != null ? ParseEmbedAuthor(json["author"]) : null;
// Get fields
var fields = json["fields"].EmptyIfNull().Select(ParseEmbedField).ToArray();
// Get thumbnail
var thumbnail = json["thumbnail"] != null ? ParseEmbedImage(json["thumbnail"]) : null;
// Get image
var image = json["image"] != null ? ParseEmbedImage(json["image"]) : null;
// Get footer
var footer = json["footer"] != null ? ParseEmbedFooter(json["footer"]) : null;
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
}
private Message ParseMessage(JToken json)
{
// Get basic data
var id = json["id"].Value<string>();
var channelId = json["channel_id"].Value<string>();
var timestamp = json["timestamp"].Value<DateTime>();
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>();
var content = json["content"].Value<string>();
var type = (MessageType) json["type"].Value<int>();
// Workarounds for non-default types
if (type == MessageType.RecipientAdd)
content = "Added a recipient.";
else if (type == MessageType.RecipientRemove)
content = "Removed a recipient.";
else if (type == MessageType.Call)
content = "Started a call.";
else if (type == MessageType.ChannelNameChange)
content = "Changed the channel name.";
else if (type == MessageType.ChannelIconChange)
content = "Changed the channel icon.";
else if (type == MessageType.ChannelPinnedMessage)
content = "Pinned a message.";
else if (type == MessageType.GuildMemberJoin)
content = "Joined the server.";
// Get author
var author = ParseUser(json["author"]);
// Get attachments
var attachments = json["attachments"].EmptyIfNull().Select(ParseAttachment).ToArray();
// Get embeds
var embeds = json["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray();
// Get mentioned users
var mentionedUsers = json["mentioned_users"].EmptyIfNull().Select(ParseUser).ToArray();
return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
mentionedUsers);
}
}
}

View File

@@ -2,297 +2,29 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
using System.Drawing;
using System.Numerics;
using DiscordChatExporter.Core.Internal;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService : IDataService, IDisposable
{
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>();
private User ParseUser(JToken token)
private async Task<JToken> GetApiResponseAsync(string token, string resource, string endpoint, params string[] parameters)
{
var id = token["id"].Value<string>();
var discriminator = token["discriminator"].Value<int>();
var name = token["username"].Value<string>();
var avatarHash = token["avatar"].Value<string>();
// Format URL
const string apiRoot = "https://discordapp.com/api/v6";
var url = $"{apiRoot}/{resource}/{endpoint}?token={token}";
return new User(id, discriminator, name, avatarHash);
}
// Add parameters
foreach (var parameter in parameters)
url += $"&{parameter}";
private Role ParseRole(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
return new Role(id, name);
}
private Guild ParseGuild(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
var iconHash = token["icon"].Value<string>();
var roles = token["roles"].Select(ParseRole).ToArray();
return new Guild(id, name, iconHash, roles);
}
private Channel ParseChannel(JToken token)
{
// Get basic data
var id = token["id"].Value<string>();
var guildId = token["guild_id"]?.Value<string>();
var type = (ChannelType) token["type"].Value<int>();
var topic = token["topic"]?.Value<string>();
// Extract name based on type
string name;
if (type.IsEither(ChannelType.DirectTextChat, ChannelType.DirectGroupTextChat))
{
guildId = Guild.DirectMessages.Id;
// Try to get name if it's set
name = token["name"]?.Value<string>();
// Otherwise use recipients as the name
if (name.IsBlank())
name = token["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
}
else
{
name = token["name"].Value<string>();
}
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
var id = token["id"].Value<string>();
var channelId = token["channel_id"].Value<string>();
var timeStamp = token["timestamp"].Value<DateTime>();
var editedTimeStamp = token["edited_timestamp"]?.Value<DateTime?>();
var content = token["content"].Value<string>();
var type = (MessageType) token["type"].Value<int>();
// Workarounds for non-default types
if (type == MessageType.RecipientAdd)
content = "Added a recipient.";
else if (type == MessageType.RecipientRemove)
content = "Removed a recipient.";
else if (type == MessageType.Call)
content = "Started a call.";
else if (type == MessageType.ChannelNameChange)
content = "Changed the channel name.";
else if (type == MessageType.ChannelIconChange)
content = "Changed the channel icon.";
else if (type == MessageType.ChannelPinnedMessage)
content = "Pinned a message.";
else if (type == MessageType.GuildMemberJoin)
content = "Joined the server.";
// Get author
var author = ParseUser(token["author"]);
// Get attachment
var attachments = new List<Attachment>();
foreach (var attachmentJson in token["attachments"].EmptyIfNull())
{
var attachmentId = attachmentJson["id"].Value<string>();
var attachmentUrl = attachmentJson["url"].Value<string>();
var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image
: AttachmentType.Other;
var attachmentFileName = attachmentJson["filename"].Value<string>();
var attachmentFileSize = attachmentJson["size"].Value<long>();
var attachment = new Attachment(
attachmentId, attachmentType, attachmentUrl,
attachmentFileName, attachmentFileSize);
attachments.Add(attachment);
}
// Get embeds
var embeds = token["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray();
// Get user mentions
var mentionedUsers = token["mentions"].Select(ParseUser).ToList();
// Get role mentions
var mentionedRoles = token["mention_roles"]
.Values<string>()
.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(i))
.ToList();
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)
{
// Send request
using (var response = await _httpClient.GetAsync(url))
{
// Check status code
@@ -301,213 +33,84 @@ namespace DiscordChatExporter.Core.Services
throw new HttpErrorStatusCodeException(response.StatusCode);
// Get content
return await response.Content.ReadAsStringAsync();
var raw = await response.Content.ReadAsStringAsync();
// Parse
return JToken.Parse(raw);
}
}
public async Task<Guild> GetGuildAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var guild = ParseGuild(JToken.Parse(content));
// Add roles to cache
foreach (var role in guild.Roles)
_roleCache[role.Id] = role;
var response = await GetApiResponseAsync(token, "guilds", guildId);
var guild = ParseGuild(response);
return guild;
}
public async Task<Channel> GetChannelAsync(string token, string channelId)
{
// Form request url
var url = $"{ApiRoot}/channels/{channelId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channel = ParseChannel(JToken.Parse(content));
// Add channel to cache
_channelCache[channel.Id] = channel;
var response = await GetApiResponseAsync(token, "channels", channelId);
var channel = ParseChannel(response);
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
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
// Add channels to cache
foreach (var channel in channels)
_channelCache[channel.Id] = channel;
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
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
// Get response
var content = await GetStringAsync(url);
// Parse IDs
var guildIds = JArray.Parse(content).Select(t => t["id"].Value<string>());
// Get full guild infos
var guilds = new List<Guild>();
foreach (var guildId in guildIds)
{
var guild = await GetGuildAsync(token, guildId);
guilds.Add(guild);
}
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
var guilds = response.Select(ParseGuild).ToArray();
return guilds;
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
var response = await GetApiResponseAsync(token, "users", "@me/channels");
var channels = response.Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<User>> GetGuildMembersAsync(string token, string guildId)
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(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}";
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
var channels = response.Select(ParseChannel).ToArray();
// Get response
var content = await GetStringAsync(url);
return channels;
}
// Parse
var users = JArray.Parse(content).Select(m => ParseUser(m["user"]));
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId)
{
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
var roles = response.Select(ParseRole).ToArray();
// 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;
return roles;
}
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
// collecting everything between them in batches
var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
var beforeId = to?.ToSnowflake() ?? DateTime.MaxValue.ToSnowflake();
while (true)
{
// Form request url
var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
if (beforeId.IsNotBlank())
url += $"&before={beforeId}";
// Get response
var content = await GetStringAsync(url);
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=100", $"before={beforeId}");
// Parse
var messages = JArray.Parse(content).Select(ParseMessage);
var messages = response.Select(ParseMessage);
// Add messages to list
string currentMessageId = null;
foreach (var message in messages)
{
// Break when the message is older than from date
if (from != null && message.TimeStamp < from)
if (from != null && message.Timestamp < from)
{
currentMessageId = null;
break;
@@ -529,39 +132,39 @@ 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;
}
protected virtual void Dispose(bool disposing)
public async Task<Mentionables> GetMentionablesAsync(string token, string guildId,
IEnumerable<Message> messages)
{
if (disposing)
// Get channels and roles
var channels = guildId != Guild.DirectMessages.Id
? await GetGuildChannelsAsync(token, guildId)
: Array.Empty<Channel>();
var roles = guildId != Guild.DirectMessages.Id
? await GetGuildRolesAsync(token, guildId)
: Array.Empty<Role>();
// Get users
var userMap = new Dictionary<string, User>();
foreach (var message in messages)
{
_httpClient.Dispose();
// Author
userMap[message.Author.Id] = message.Author;
// Mentioned users
foreach (var mentionedUser in message.MentionedUsers)
userMap[mentionedUser.Id] = mentionedUser;
}
var users = userMap.Values.ToArray();
return new Mentionables(users, channels, roles);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
public partial class DataService
{
private static string DateTimeToSnowflake(DateTime dateTime)
{
const long epoch = 62135596800000;
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
_httpClient.Dispose();
}
}
}

View File

@@ -1,76 +0,0 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CsvHelper;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentCsv(Message message)
{
var content = message.Content;
// New lines
content = content.Replace("\n", ", ");
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private async Task ExportAsCsvAsync(ChannelChatLog log, TextWriter output)
{
using (var writer = new CsvWriter(output))
{
// Headers
writer.WriteField("Author");
writer.WriteField("Date");
writer.WriteField("Content");
writer.WriteField("Attachments");
await writer.NextRecordAsync();
// Chat log
foreach (var group in log.MessageGroups)
{
// Messages
foreach (var msg in group.Messages)
{
// Author
writer.WriteField(msg.Author.FullName);
// Date
var timeStampFormatted = msg.TimeStamp.ToString(_settingsService.DateFormat);
writer.WriteField(timeStampFormatted);
// Content
var contentFormatted = msg.Content.IsNotBlank() ? FormatMessageContentCsv(msg) : null;
writer.WriteField(contentFormatted);
// Attachments
var attachmentsFormatted = msg.Attachments.Select(a => a.Url).JoinToString(",");
writer.WriteField(attachmentsFormatted);
await writer.NextRecordAsync();
}
}
}
}
}
}

View File

@@ -1,365 +0,0 @@
using System.IO;
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 MarkdownToHtml(string content, IMentionable mentionable = null, bool allowLinks = false)
{
// A lot of these regexes were inspired by or taken from MarkdownSharp
// HTML-encode content
content = HtmlEncode(content);
// Encode multiline codeblocks (```text```)
content = Regex.Replace(content,
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
m => $"\x1AM{Base64Encode(m.Groups[1].Value)}\x1AM");
// Encode inline codeblocks (`text`)
content = Regex.Replace(content,
@"`([^`]+)`",
m => $"\x1AI{Base64Encode(m.Groups[1].Value)}\x1AI");
// Encode URLs
content = Regex.Replace(content,
@"(\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**)
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
// Process underline (__text__)
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
// Process italic (*text* or _text_)
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
// Process strike through (~~text~~)
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
// Decode and process multiline codeblocks
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
m => $"<div class=\"pre\">{Base64Decode(m.Groups[1].Value)}</div>");
// Decode and process inline codeblocks
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>");
// New lines
content = content.Replace("\n", "<br />");
// Meta mentions (@everyone)
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
if (mentionable != null)
{
// 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 mentionable.MentionedRoles)
{
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.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>)
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
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
await output.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Html start
await output.WriteLineAsync("<!DOCTYPE html>");
await output.WriteLineAsync("<html lang=\"en\">");
// HEAD
await output.WriteLineAsync("<head>");
await output.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
await output.WriteLineAsync("<meta charset=\"utf-8\" />");
await output.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await output.WriteLineAsync($"<style>{css}</style>");
await output.WriteLineAsync("</head>");
// Body start
await output.WriteLineAsync("<body>");
// Guild and channel info
await output.WriteLineAsync("<div id=\"info\">");
await output.WriteLineAsync("<div class=\"info-left\">");
await output.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await output.WriteLineAsync("</div>"); // info-left
await output.WriteLineAsync("<div class=\"info-right\">");
await output.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-topic\">{log.Channel.Topic}</div>");
await output.WriteLineAsync(
$"<div class=\"channel-messagecount\">{log.TotalMessageCount:N0} messages</div>");
await output.WriteLineAsync("</div>"); // info-right
await output.WriteLineAsync("</div>"); // info
// Chat log
await output.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{
await output.WriteLineAsync("<div class=\"msg\">");
await output.WriteLineAsync("<div class=\"msg-left\">");
await output.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
await output.WriteLineAsync("</div>");
await output.WriteLineAsync("<div class=\"msg-right\">");
await output.WriteAsync(
$"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author.FullName)}\">");
await output.WriteAsync(HtmlEncode(group.Author.Name));
await output.WriteLineAsync("</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
await output.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
await output.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContentHtml(message);
await output.WriteAsync(contentFormatted);
// Edited timestamp
if (message.EditedTimeStamp != null)
{
var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
await output.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
await output.WriteLineAsync("</div>"); // msg-content
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.Type == AttachmentType.Image)
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await output.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
else
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
await output.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
await output.WriteLineAsync("</a>");
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
}
await output.WriteLineAsync("</div>"); // log
await output.WriteLineAsync("</body>");
await output.WriteLineAsync("</html>");
}
}
}

View File

@@ -1,79 +0,0 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentPlainText(Message message)
{
var content = message.Content;
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private async Task ExportAsPlainTextAsync(ChannelChatLog log, TextWriter output)
{
// Generation info
await output.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter");
await output.WriteLineAsync();
// Guild and channel info
await output.WriteLineAsync('='.Repeat(48));
await output.WriteLineAsync($"Guild: {log.Guild.Name}");
await output.WriteLineAsync($"Channel: {log.Channel.Name}");
await output.WriteLineAsync($"Topic: {log.Channel.Topic}");
await output.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}");
await output.WriteLineAsync('='.Repeat(48));
await output.WriteLineAsync();
// Chat log
foreach (var group in log.MessageGroups)
{
var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat);
await output.WriteLineAsync($"{group.Author.FullName} [{timeStampFormatted}]");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var contentFormatted = FormatMessageContentPlainText(message);
await output.WriteLineAsync(contentFormatted);
}
// Attachments
foreach (var attachment in message.Attachments)
{
await output.WriteLineAsync(attachment.Url);
}
}
await output.WriteLineAsync();
}
}
}
}

View File

@@ -0,0 +1,37 @@
using System.Reflection;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private class TemplateLoader : ITemplateLoader
{
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Resources.ExportTemplates";
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName)
{
return $"{ResourceRootNamespace}.{templateName}";
}
public string GetPath(ExportFormat format)
{
return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}";
}
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
{
return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath);
}
public string Load(ExportFormat format)
{
return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format));
}
}
}
}

View File

@@ -0,0 +1,325 @@
using System;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private class TemplateModel
{
private readonly ExportFormat _format;
private readonly ChatLog _log;
private readonly string _dateFormat;
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat)
{
_format = format;
_log = log;
_dateFormat = dateFormat;
}
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
private string HtmlDecode(string str) => WebUtility.HtmlDecode(str);
private string Format(IFormattable obj, string format) =>
obj.ToString(format, CultureInfo.InvariantCulture);
private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat);
private string FormatFileSize(long fileSize)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
var unit = 0;
while (size >= 1024)
{
size /= 1024;
++unit;
}
return $"{size:0.#} {units[unit]}";
}
private string FormatColor(Color color)
{
return $"{color.R},{color.G},{color.B},{color.A}";
}
private string FormatContentPlainText(string content)
{
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private string FormatContentHtml(string content, bool allowLinks = false)
{
// HTML-encode content
content = HtmlEncode(content);
// Encode multiline codeblocks (```text```)
content = Regex.Replace(content,
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM");
// Encode inline codeblocks (`text`)
content = Regex.Replace(content,
@"`([^`]+)`",
m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI");
// Encode links
if (allowLinks)
{
content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)",
m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL");
}
// Encode URLs
content = Regex.Replace(content,
@"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))",
m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU");
// Process bold (**text**)
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
// Process underline (__text__)
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
// Process italic (*text* or _text_)
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
// Process strike through (~~text~~)
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
// Decode and process multiline codeblocks
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
m => $"<div class=\"pre-multiline\">{m.Groups[1].Value.Base64Decode()}</div>");
// Decode and process inline codeblocks
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre-inline\">{m.Groups[1].Value.Base64Decode()}</span>");
// Decode and process links
if (allowLinks)
{
content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL",
m => $"<a href=\"{m.Groups[2].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
}
// Decode and process URLs
content = Regex.Replace(content, "\x1AU(.*?)\x1AU",
m => $"<a href=\"{m.Groups[1].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
// Process new lines
content = content.Replace("\n", "<br />");
// Meta mentions (@everyone)
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "&lt;@!?(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"&lt;@!?{mentionedUserId}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "&lt;#(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"&lt;#{mentionedChannelId}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "&lt;@&amp;(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"&lt;@&amp;{mentionedRoleId}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
private string FormatContentCsv(string content)
{
// New lines
content = content.Replace("\n", ", ");
// Escape quotes
content = content.Replace("\"", "\"\"");
// Escape commas and semicolons
if (content.Contains(",") || content.Contains(";"))
content = $"\"{content}\"";
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private string FormatContent(string content, bool allowLinks = false)
{
if (_format == ExportFormat.PlainText)
return FormatContentPlainText(content);
if (_format == ExportFormat.HtmlDark)
return FormatContentHtml(content, allowLinks);
if (_format == ExportFormat.HtmlLight)
return FormatContentHtml(content, allowLinks);
if (_format == ExportFormat.Csv)
return FormatContentCsv(content);
throw new ArgumentOutOfRangeException(nameof(_format));
}
public ScriptObject GetScriptObject()
{
// Create instance
var scriptObject = new ScriptObject();
// Import chat log
scriptObject.Import(_log, TemplateMemberFilter, TemplateMemberRenamer);
// Import functions
scriptObject.Import(nameof(HtmlEncode), new Func<string, string>(HtmlEncode));
scriptObject.Import(nameof(HtmlDecode), new Func<string, string>(HtmlDecode));
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
scriptObject.Import(nameof(FormatColor), new Func<Color, string>(FormatColor));
scriptObject.Import(nameof(FormatContent), new Func<string, bool, string>(FormatContent));
return scriptObject;
}
}
}
}

View File

@@ -1,16 +1,15 @@
using System;
using System.IO;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Internal;
using System.IO;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
using Scriban;
using Scriban.Runtime;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService : IExportService
{
private static readonly MemberRenamerDelegate TemplateMemberRenamer = m => m.Name;
private static readonly MemberFilterDelegate TemplateMemberFilter = m => true;
private readonly ISettingsService _settingsService;
public ExportService(ISettingsService settingsService)
@@ -18,69 +17,36 @@ namespace DiscordChatExporter.Core.Services
_settingsService = settingsService;
}
public async Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log)
public void Export(ExportFormat format, string filePath, ChatLog log)
{
// Create template loader
var loader = new TemplateLoader();
// Get template
var templateCode = loader.Load(format);
var template = Template.Parse(templateCode);
// Create template context
var context = new TemplateContext
{
TemplateLoader = loader,
MemberRenamer = TemplateMemberRenamer,
MemberFilter = TemplateMemberFilter
};
// Create template model
var templateModel = new TemplateModel(format, log, _settingsService.DateFormat);
context.PushGlobal(templateModel.GetScriptObject());
// Render output
using (var output = File.CreateText(filePath))
{
var sharedCss = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.Shared.css");
// Configure output
context.PushOutput(new TextWriterOutput(output));
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, $"{sharedCss}\n{css}");
}
else if (format == ExportFormat.HtmlLight)
{
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css");
await ExportAsHtmlAsync(log, output, $"{sharedCss}\n{css}");
}
else if (format == ExportFormat.Csv)
{
await ExportAsCsvAsync(log, output);
}
else throw new ArgumentOutOfRangeException(nameof(format));
// Render template
template.Render(context);
}
}
}
public partial class ExportService
{
private static string Base64Encode(string str)
{
return str.GetBytes().ToBase64();
}
private static string Base64Decode(string str)
{
return str.FromBase64().GetString();
}
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static string FormatFileSize(long fileSize)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
var unit = 0;
while (size >= 1024)
{
size /= 1024;
++unit;
}
return $"{size:0.#} {units[unit]}";
}
}
}

View File

@@ -11,13 +11,18 @@ namespace DiscordChatExporter.Core.Services
Task<Channel> GetChannelAsync(string token, string channelId);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to);
Task<Mentionables> GetMentionablesAsync(string token, string guildId,
IEnumerable<Message> messages);
}
}

View File

@@ -1,10 +1,9 @@
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IExportService
{
Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log);
void Export(ExportFormat format, string filePath, ChatLog log);
}
}

View File

@@ -5,6 +5,6 @@ namespace DiscordChatExporter.Core.Services
{
public interface IMessageGroupService
{
IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages);
IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages);
}
}

View File

@@ -13,7 +13,7 @@ namespace DiscordChatExporter.Core.Services
_settingsService = settingsService;
}
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
public IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{
var result = new List<MessageGroup>();
@@ -28,15 +28,15 @@ namespace DiscordChatExporter.Core.Services
groupFirst != null &&
(
message.Author.Id != groupFirst.Author.Id ||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour ||
(message.Timestamp - groupFirst.Timestamp).TotalHours > 1 ||
message.Timestamp.Hour != groupFirst.Timestamp.Hour ||
groupBuffer.Count >= _settingsService.MessageGroupLimit
);
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
result.Add(group);
groupBuffer.Clear();
}
@@ -49,7 +49,7 @@ namespace DiscordChatExporter.Core.Services
if (groupBuffer.Any())
{
var groupFirst = groupBuffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
result.Add(group);
}