Make it work

pull/4/head
Alexey Golub 7 years ago
parent 01ad2fd711
commit eb59cbde28

@ -0,0 +1,22 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26430.13
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{4BE915D1-129C-49E2-860E-62045ACA5EAD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net45</TargetFramework>
</PropertyGroup>
<ItemGroup>
<None Remove="Services\ExportTemplate.html" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Services\ExportTemplate.html" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="1.9.71" />
<PackageReference Include="HtmlAgilityPack" Version="1.5.1" />
<PackageReference Include="Newtonsoft.json" Version="10.0.3" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.4.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
</ItemGroup>
</Project>

@ -0,0 +1,21 @@
namespace DiscordChatExporter.Models
{
public class Attachment
{
public string Id { get; }
public string Url { get; }
public string FileName { get; }
public long ContentLength { get; }
public Attachment(string id, string url, string fileName, long contentLength)
{
Id = id;
Url = url;
FileName = fileName;
ContentLength = contentLength;
}
}
}

@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Models
{
public class ChatLog
{
public string ChannelId { get; }
public IReadOnlyList<User> Participants { get; }
public IReadOnlyList<Message> Messages { get; }
public ChatLog(string channelId, IEnumerable<Message> messages)
{
ChannelId = channelId;
Messages = messages.ToArray();
Participants = Messages.Select(m => m.Author).Distinct(a => a.Name).ToArray();
}
}
}

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models
{
public class Message
{
public string Id { get; }
public DateTime TimeStamp { get; }
public User Author { get; }
public string Content { get; }
public IReadOnlyList<Attachment> Attachments { get; }
public Message(string id, DateTime timeStamp, User author, string content, IEnumerable<Attachment> attachments)
{
Id = id;
TimeStamp = timeStamp;
Author = author;
Content = content;
Attachments = attachments.ToArray();
}
public override string ToString()
{
return Content;
}
}
}

@ -0,0 +1,29 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Models
{
public class User
{
public string Id { get; }
public string Name { get; }
public string AvatarHash { get; }
public string AvatarUrl => AvatarHash.IsNotBlank()
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size=256"
: "https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.png";
public User(string id, string name, string avatarHash)
{
Id = id;
Name = name;
AvatarHash = avatarHash;
}
public override string ToString()
{
return Name;
}
}
}

@ -0,0 +1,32 @@
using CommandLine;
using CommandLine.Text;
namespace DiscordChatExporter
{
public class Options
{
[Option('t', "token", Required = true, HelpText = "Discord access token")]
public string Token { get; set; }
[Option('c', "channel", Required = true, HelpText = "ID of the text channel to export")]
public string ChannelId { get; set; }
[HelpOption]
public string GetUsage()
{
var help = new HelpText
{
Heading = new HeadingInfo("DiscordChatExporter"),
Copyright = new CopyrightInfo("Alexey 'Tyrrrz' Golub", 2017),
AdditionalNewLineAfterOption = true,
AddDashesToOption = true
};
help.AddPreOptionsLine("Usage: DiscordChatExporter.exe " +
"-t REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 " +
"-c 459360869055190534");
help.AddOptions(this);
return help;
}
}
}

@ -0,0 +1,37 @@
using System;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
namespace DiscordChatExporter
{
public static class Program
{
private static readonly Options Options = new Options();
private static readonly DiscordApiService DiscordApiService = new DiscordApiService();
private static readonly ExportService ExportService = new ExportService();
private static async Task MainAsync(string[] args)
{
// Parse cmd args
CommandLine.Parser.Default.ParseArgumentsStrict(args, Options);
// Get messages
Console.WriteLine("Getting messages...");
var messages = await DiscordApiService.GetMessagesAsync(Options.Token, Options.ChannelId);
var chatLog = new ChatLog(Options.ChannelId, messages);
// Export
Console.WriteLine("Exporting messages...");
ExportService.Export($"{Options.ChannelId}.html", chatLog);
}
public static void Main(string[] args)
{
Console.Title = "Discord Chat Exporter";
MainAsync(args).GetAwaiter().GetResult();
}
}
}

@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
{
public class DiscordApiService
{
private const string ApiRoot = "https://discordapp.com/api";
private readonly HttpClient _httpClient = new HttpClient();
private IEnumerable<Message> ParseMessages(string json)
{
var messagesJson = JArray.Parse(json);
foreach (var messageJson in messagesJson)
{
// Get basic data
string id = messageJson.Value<string>("id");
var timeStamp = messageJson.Value<DateTime>("timestamp");
string content = messageJson.Value<string>("content");
// Get author
var authorJson = messageJson["author"];
string authorId = authorJson.Value<string>("id");
string authorName = authorJson.Value<string>("username");
string authorAvatarHash = authorJson.Value<string>("avatar");
// Get attachment
var attachmentsJson = messageJson["attachments"];
var attachments = new List<Attachment>();
foreach (var attachmentJson in attachmentsJson)
{
string attachmentId = attachmentJson.Value<string>("id");
string attachmentUrl = attachmentJson.Value<string>("url");
string attachmentFileName = attachmentJson.Value<string>("filename");
long attachmentContentLength = attachmentJson.Value<long>("size");
var attachment = new Attachment(attachmentId, attachmentUrl, attachmentFileName, attachmentContentLength);
attachments.Add(attachment);
}
var author = new User(authorId, authorName, authorAvatarHash);
var message = new Message(id, timeStamp, author, content, attachments);
yield return message;
}
}
public async Task<IEnumerable<Message>> GetMessagesAsync(string token, string channelId)
{
var result = new List<Message>();
// We are going backwards from last message to first
// ...collecting everything between them in batches
string beforeId = null;
while (true)
{
// Form request url
string url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
if (beforeId.IsNotBlank())
url += $"&before={beforeId}";
// Get response
string response = await _httpClient.GetStringAsync(url);
// Parse
var messages = ParseMessages(response);
// Add messages to list
string currentMessageId = null;
foreach (var message in messages)
{
result.Add(message);
currentMessageId = message.Id;
}
// If no messages - break
if (currentMessageId == null) break;
// Otherwise offset the next request
beforeId = currentMessageId;
}
// Messages appear newest first, we need to reverse
result.Reverse();
return result;
}
}
}

@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using DiscordChatExporter.Models;
using HtmlAgilityPack;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
{
public class ExportService
{
private class MessageGroup
{
public User Author { get; }
public DateTime FirstTimeStamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable<Message> messages)
{
Author = author;
FirstTimeStamp = firstTimeStamp;
Messages = messages.ToArray();
}
}
private HtmlDocument GetTemplate()
{
const string templateName = "DiscordChatExporter.Services.ExportTemplate.html";
var assembly = Assembly.GetExecutingAssembly();
using (var stream = assembly.GetManifestResourceStream(templateName))
{
var doc = new HtmlDocument();
doc.Load(stream);
return doc;
}
}
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{
var result = new List<MessageGroup>();
// Group adjacent messages by timestamp and author
var buffer = new List<Message>();
foreach (var message in messages)
{
var bufferFirst = buffer.FirstOrDefault();
// Group break condition
bool breakCondition =
bufferFirst != null &&
(
message.Author.Id != bufferFirst.Author.Id ||
(message.TimeStamp - bufferFirst.TimeStamp).TotalHours > 1 ||
message.TimeStamp.Hour != bufferFirst.TimeStamp.Hour
);
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(bufferFirst.Author, bufferFirst.TimeStamp, buffer);
result.Add(group);
buffer.Clear();
}
// Add message to buffer
buffer.Add(message);
}
// Add what's remaining in buffer
if (buffer.Any())
{
var bufferFirst = buffer.First();
var group = new MessageGroup(bufferFirst.Author, bufferFirst.TimeStamp, buffer);
result.Add(group);
}
return result;
}
private string FormatMessageContent(string content)
{
// Encode HTML
content = HtmlDocument.HtmlEncode(content);
// Links from URLs
content = Regex.Replace(content, "((^|\\s)(https?|ftp)://[^\\s/$.?#].[^\\s]*($|\\s))",
"<a href=\"$1\">$1</a>");
// Preformatted multiline
content = Regex.Replace(content, "```([^`]*?)```", e => "<pre>" + e.Groups[1].Value + "</pre>");
// Preformatted
content = Regex.Replace(content, "`([^`]*?)`", e => "<pre>" + e.Groups[1].Value + "</pre>");
// Bold
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
// Italic
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
// Underline
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
// Strike through
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
// New lines
content = content.Replace("\n", "</br>");
return content;
}
public void Export(string filePath, ChatLog chatLog)
{
var doc = GetTemplate();
// Info
var infoHtml = doc.GetElementbyId("info");
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
string participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Participants: <b>{participants}</b></div>"));
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Messages: <b>{chatLog.Messages.Count:N0}</b></div>"));
// Messages
var logHtml = doc.GetElementbyId("log");
var messageGroups = GroupMessages(chatLog.Messages);
foreach (var messageGroup in messageGroups)
{
// Container
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
// Avatar
messageHtml.AppendChild(HtmlNode.CreateNode("<img class=\"msg-avatar\" " +
$"src=\"{messageGroup.Author.AvatarUrl}\"></img>"));
// Body
var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-body\"></div>"));
// Author
string authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
// Date
string timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
// Separate messages
foreach (var message in messageGroup.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
string content = FormatMessageContent(message.Content);
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
}
// Attachments
if (message.Attachments.Any())
{
// Attachments
foreach (var attachment in message.Attachments)
{
messageBodyHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
"</a></div>"));
}
}
}
}
doc.Save(filePath);
}
}
}

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Discord Chat Log</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #ffffff;
font-size: 15px;
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
}
a {
text-decoration: none;
color: #37bcf7;
}
a:hover {
text-decoration: underline;
}
pre {
margin: 0;
padding-left: 2px;
padding-right: 2px;
background-color: #f9f9f9;
font-family: Consolas, Courier New, Courier, Monospace;
display: inline;
}
div#info {
width: 100%;
margin-bottom: 20px;
color: #939799;
}
div#log {
width: 100%;
}
div.msg {
display: flex;
margin-left: 10px;
margin-right: 10px;
padding-top: 15px;
padding-bottom: 15px;
border-top: 1px solid #eceeef;
}
img.msg-avatar {
flex: 0;
width: 40px;
height: 40px;
border-radius: 50%;
}
div.msg-body {
flex: 1;
margin-left: 15px;
}
span.msg-user {
color: #2f3136;
font-size: 1.15em;
}
span.msg-date {
margin-left: 5px;
color: #b7bcbf;
font-size: 0.8em;
font-weight: 200;
}
div.msg-content {
padding-top: 5px;
color: #939799;
}
div.msg-attachment {
margin-top: 5px;
margin-bottom: 5px;
}
img.msg-attachment {
max-width: 50%;
max-height: 500px;
}
</style>
</head>
<body>
<div id="info" />
<div id="log" />
</body>
</html>
Loading…
Cancel
Save