mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 22:54:38 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f63ea41188 | |||
| 3572a21aad | |||
| 37ee0b8be3 | |||
| ed146bac22 | |||
| b9c449709f | |||
| 25207c5093 | |||
| 4fbec95208 |
@@ -1,3 +1,14 @@
|
|||||||
|
### v2.6 (25-Jul-2018)
|
||||||
|
|
||||||
|
- Added support for bot tokens as an alternative to user tokens. For GUI, use the button in the top-left to switch between user and bot token. For CLI, pass the `--bot` switch to indicate that the given token is a bot token.
|
||||||
|
- Added support for animated avatars.
|
||||||
|
|
||||||
|
### v2.5.1 (01-Jul-2018)
|
||||||
|
|
||||||
|
- Fixed a bug that would prevent timestamps from rendering properly in CSV and PlainText exports.
|
||||||
|
- Fixed incorrect layout in CSV export.
|
||||||
|
- Improved readability of PlainText export.
|
||||||
|
|
||||||
### v2.5 (28-Jun-2018)
|
### v2.5 (28-Jun-2018)
|
||||||
|
|
||||||
- Added support for embeds and webhooks (HTML only).
|
- Added support for embeds and webhooks (HTML only).
|
||||||
|
|||||||
+1
-1
@@ -8,4 +8,4 @@ $files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporte
|
|||||||
# CLI
|
# CLI
|
||||||
$files = @()
|
$files = @()
|
||||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
||||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.Cli.zip" -Force
|
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
|
||||||
@@ -5,7 +5,9 @@ namespace DiscordChatExporter.Cli
|
|||||||
{
|
{
|
||||||
public class CliOptions
|
public class CliOptions
|
||||||
{
|
{
|
||||||
public string Token { get; set; }
|
public string TokenValue { get; set; }
|
||||||
|
|
||||||
|
public bool IsBotToken { get; set; }
|
||||||
|
|
||||||
public string ChannelId { get; set; }
|
public string ChannelId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net461</TargetFramework>
|
<TargetFramework>net461</TargetFramework>
|
||||||
<Version>2.5</Version>
|
<Version>2.6</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
|
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
|
||||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace DiscordChatExporter.Cli
|
|||||||
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ===");
|
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ===");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine("[-t] [--token] Discord authorization token.");
|
Console.WriteLine("[-t] [--token] Discord authorization token.");
|
||||||
|
Console.WriteLine("[-b] [--bot] Whether this is a bot token.");
|
||||||
Console.WriteLine("[-c] [--channel] Discord channel ID.");
|
Console.WriteLine("[-c] [--channel] Discord channel ID.");
|
||||||
Console.WriteLine("[-f] [--format] Export format. Optional.");
|
Console.WriteLine("[-f] [--format] Export format. Optional.");
|
||||||
Console.WriteLine("[-o] [--output] Output file path. Optional.");
|
Console.WriteLine("[-o] [--output] Output file path. Optional.");
|
||||||
@@ -28,20 +29,27 @@ namespace DiscordChatExporter.Cli
|
|||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine($"Available export formats: {availableFormats.JoinToString(", ")}");
|
Console.WriteLine($"Available export formats: {availableFormats.JoinToString(", ")}");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine("# To get authorization token:");
|
Console.WriteLine("# To get user token:");
|
||||||
Console.WriteLine(" - Open Discord app");
|
Console.WriteLine(" - Open Discord app");
|
||||||
Console.WriteLine(" - Log in if you haven't");
|
Console.WriteLine(" - Log in if you haven't");
|
||||||
Console.WriteLine(" - Press Ctrl+Shift+I");
|
Console.WriteLine(" - Press Ctrl+Shift+I to show developer tools");
|
||||||
Console.WriteLine(" - Navigate to Application tab");
|
Console.WriteLine(" - Navigate to the Application tab");
|
||||||
Console.WriteLine(" - Expand Storage > Local Storage > https://discordapp.com");
|
Console.WriteLine(" - Expand Storage > Local Storage > https://discordapp.com");
|
||||||
Console.WriteLine(" - Find \"token\" under key and copy the value");
|
Console.WriteLine(" - Find the \"token\" key and copy its value");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("# To get bot token:");
|
||||||
|
Console.WriteLine(" - Go to Discord developer portal");
|
||||||
|
Console.WriteLine(" - Log in if you haven't");
|
||||||
|
Console.WriteLine(" - Open your application's settings");
|
||||||
|
Console.WriteLine(" - Navigate to the Bot section on the left");
|
||||||
|
Console.WriteLine(" - Under Token click Copy");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine("# To get channel ID:");
|
Console.WriteLine("# To get channel ID:");
|
||||||
Console.WriteLine(" - Open Discord app");
|
Console.WriteLine(" - Open Discord app");
|
||||||
Console.WriteLine(" - Log in if you haven't");
|
Console.WriteLine(" - Log in if you haven't");
|
||||||
Console.WriteLine(" - Go to any channel you want to export");
|
Console.WriteLine(" - Go to any channel you want to export");
|
||||||
Console.WriteLine(" - Press Ctrl+Shift+I");
|
Console.WriteLine(" - Press Ctrl+Shift+I to show developer tools");
|
||||||
Console.WriteLine(" - Navigate to Console tab");
|
Console.WriteLine(" - Navigate to the Console tab");
|
||||||
Console.WriteLine(" - Type \"document.URL\" and press Enter");
|
Console.WriteLine(" - Type \"document.URL\" and press Enter");
|
||||||
Console.WriteLine(" - Copy the long sequence of numbers after last slash");
|
Console.WriteLine(" - Copy the long sequence of numbers after last slash");
|
||||||
}
|
}
|
||||||
@@ -52,7 +60,8 @@ namespace DiscordChatExporter.Cli
|
|||||||
|
|
||||||
var settings = Container.SettingsService;
|
var settings = Container.SettingsService;
|
||||||
|
|
||||||
argsParser.Setup(o => o.Token).As('t', "token").Required();
|
argsParser.Setup(o => o.TokenValue).As('t', "token").Required();
|
||||||
|
argsParser.Setup(o => o.IsBotToken).As('b', "bot").SetDefault(false);
|
||||||
argsParser.Setup(o => o.ChannelId).As('c', "channel").Required();
|
argsParser.Setup(o => o.ChannelId).As('c', "channel").Required();
|
||||||
argsParser.Setup(o => o.ExportFormat).As('f', "format").SetDefault(ExportFormat.HtmlDark);
|
argsParser.Setup(o => o.ExportFormat).As('f', "format").SetDefault(ExportFormat.HtmlDark);
|
||||||
argsParser.Setup(o => o.FilePath).As('o', "output").SetDefault(null);
|
argsParser.Setup(o => o.FilePath).As('o', "output").SetDefault(null);
|
||||||
@@ -92,10 +101,15 @@ namespace DiscordChatExporter.Cli
|
|||||||
settings.DateFormat = options.DateFormat;
|
settings.DateFormat = options.DateFormat;
|
||||||
settings.MessageGroupLimit = options.MessageGroupLimit;
|
settings.MessageGroupLimit = options.MessageGroupLimit;
|
||||||
|
|
||||||
|
// Create token
|
||||||
|
var token = new AuthToken(
|
||||||
|
options.IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
|
||||||
|
options.TokenValue);
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
var vm = Container.MainViewModel;
|
var vm = Container.MainViewModel;
|
||||||
vm.ExportAsync(
|
vm.ExportAsync(
|
||||||
options.Token,
|
token,
|
||||||
options.ChannelId,
|
options.ChannelId,
|
||||||
options.FilePath,
|
options.FilePath,
|
||||||
options.ExportFormat,
|
options.ExportFormat,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace DiscordChatExporter.Cli.ViewModels
|
|||||||
{
|
{
|
||||||
public interface IMainViewModel
|
public interface IMainViewModel
|
||||||
{
|
{
|
||||||
Task ExportAsync(string token, string channelId, string filePath, ExportFormat format, DateTime? from,
|
Task ExportAsync(AuthToken token, string channelId, string filePath, ExportFormat format, DateTime? from,
|
||||||
DateTime? to);
|
DateTime? to);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ namespace DiscordChatExporter.Cli.ViewModels
|
|||||||
_exportService = exportService;
|
_exportService = exportService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ExportAsync(string token, string channelId, string filePath, ExportFormat format,
|
public async Task ExportAsync(AuthToken token, string channelId, string filePath, ExportFormat format,
|
||||||
DateTime? from, DateTime? to)
|
DateTime? from, DateTime? to)
|
||||||
{
|
{
|
||||||
// Get channel and guild
|
// Get channel and guild
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net461</TargetFramework>
|
<TargetFramework>net461</TargetFramework>
|
||||||
<Version>2.5</Version>
|
<Version>2.6</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
public class AuthToken
|
||||||
|
{
|
||||||
|
public AuthTokenType Type { get; }
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public AuthToken(AuthTokenType type, string value)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
public enum AuthTokenType
|
||||||
|
{
|
||||||
|
User,
|
||||||
|
Bot
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Tyrrrz.Extensions;
|
using System;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
@@ -14,13 +15,32 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public string FullName => $"{Name}#{Discriminator:0000}";
|
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||||
|
|
||||||
public string AvatarHash { get; }
|
|
||||||
|
|
||||||
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
||||||
|
|
||||||
public string AvatarUrl => AvatarHash.IsNotBlank()
|
public string AvatarHash { get; }
|
||||||
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
|
|
||||||
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
|
public bool IsAvatarAnimated =>
|
||||||
|
AvatarHash.IsNotBlank() && AvatarHash.StartsWith("a_", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public string AvatarUrl
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Custom avatar
|
||||||
|
if (AvatarHash.IsNotBlank())
|
||||||
|
{
|
||||||
|
// Animated
|
||||||
|
if (IsAvatarAnimated)
|
||||||
|
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.gif";
|
||||||
|
|
||||||
|
// Non-animated
|
||||||
|
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default avatar
|
||||||
|
return $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public User(string id, int discriminator, string name, string avatarHash)
|
public User(string id, int discriminator, string name, string avatarHash)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
Author;Date;Content;Attachments;
|
Author;Date;Content;Attachments;
|
||||||
|
{{~ for group in Model.MessageGroups -}}
|
||||||
{{- for group in MessageGroups -}}
|
|
||||||
{{- for message in group.Messages -}}
|
{{- for message in group.Messages -}}
|
||||||
{{- message.Author.FullName }};
|
{{- message.Author.FullName }};
|
||||||
|
|
||||||
{{- message.TimeStamp | FormatDate }};
|
{{- message.Timestamp | FormatDate }};
|
||||||
|
|
||||||
{{- message.Content | FormatContent }};
|
{{- message.Content | FormatContent }};
|
||||||
|
|
||||||
|
|||||||
|
Can't render this file because it contains an unexpected character in line 11 and column 41.
|
@@ -2,208 +2,214 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>{{ Guild.Name | HtmlEncode }} - {{ Channel.Name | HtmlEncode }}</title>
|
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<style>{{ StyleSheet }}</style>
|
<style>
|
||||||
|
{{ StyleSheet }}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
{{~ # Info }}
|
{{~ # Info ~}}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="info__guild-icon-container">
|
<div class="info__guild-icon-container">
|
||||||
<img class="info__guild-icon" src="{{ Guild.IconUrl }}" />
|
<img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="info__metadata">
|
<div class="info__metadata">
|
||||||
<div class="info__guild-name">{{ Guild.Name | HtmlEncode }}</div>
|
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
|
||||||
<div class="info__channel-name">{{ Channel.Name | HtmlEncode }}</div>
|
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
|
||||||
|
|
||||||
{{ if Channel.Topic }}
|
{{~ if Model.Channel.Topic ~}}
|
||||||
<div class="info__channel-topic">{{ Channel.Topic | HtmlEncode }}</div>
|
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
<div class="info__channel-message-count">{{ TotalMessageCount | Format "N0" }} messages</div>
|
<div class="info__channel-message-count">{{ Model.TotalMessageCount | Format "N0" }} messages</div>
|
||||||
|
|
||||||
{{ if From || To }}
|
{{~ if Model.From || Model.To ~}}
|
||||||
<div class="info__channel-date-range">
|
<div class="info__channel-date-range">
|
||||||
{{ if From && To }}
|
{{~ if Model.From && Model.To ~}}
|
||||||
Between {{ From | FormatDate | HtmlEncode }} and {{ To | FormatDate | HtmlEncode }}
|
Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
|
||||||
{{ else if From }}
|
{{~ else if Model.From ~}}
|
||||||
After {{ From | FormatDate | HtmlEncode }}
|
After {{ Model.From | FormatDate | html.escape }}
|
||||||
{{ else if To }}
|
{{~ else if Model.To ~}}
|
||||||
Before {{ To | FormatDate | HtmlEncode }}
|
Before {{ Model.To | FormatDate | html.escape }}
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{~ # Log }}
|
{{~ # Log ~}}
|
||||||
<div class="chatlog">
|
<div class="chatlog">
|
||||||
{{ for group in MessageGroups }}
|
{{~ for group in Model.MessageGroups ~}}
|
||||||
<div class="chatlog__message-group">
|
<div class="chatlog__message-group">
|
||||||
{{~ # Avatar }}
|
{{~ # Avatar ~}}
|
||||||
<div class="chatlog__author-avatar-container">
|
<div class="chatlog__author-avatar-container">
|
||||||
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
|
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="chatlog__messages">
|
<div class="chatlog__messages">
|
||||||
{{~ # Author name and timestamp }}
|
{{~ # Author name and timestamp ~}}
|
||||||
<span class="chatlog__author-name" title="{{ group.Author.FullName | HtmlEncode }}">{{ group.Author.Name | HtmlEncode }}</span>
|
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}">{{ group.Author.Name | html.escape }}</span>
|
||||||
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | HtmlEncode }}</span>
|
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
|
||||||
|
|
||||||
{{~ # Messages }}
|
{{~ # Messages ~}}
|
||||||
{{ for message in group.Messages }}
|
{{~ for message in group.Messages ~}}
|
||||||
{{~ # Content }}
|
{{~ # Content ~}}
|
||||||
{{ if message.Content }}
|
{{~ if message.Content ~}}
|
||||||
<div class="chatlog__content">
|
<div class="chatlog__content">
|
||||||
{{ message.Content | FormatContent }}
|
{{ message.Content | FormatContent }}
|
||||||
|
|
||||||
{{~ # Edited timestamp }}
|
{{~ # Edited timestamp ~}}
|
||||||
{{ if message.EditedTimestamp }}
|
{{~ if message.EditedTimestamp ~}}
|
||||||
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | HtmlEncode }}">(edited)</span>
|
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Attachments }}
|
{{~ # Attachments ~}}
|
||||||
{{ for attachment in message.Attachments }}
|
{{~ for attachment in message.Attachments ~}}
|
||||||
<div class="chatlog__attachment">
|
<div class="chatlog__attachment">
|
||||||
<a href="{{ attachment.Url }}">
|
<a href="{{ attachment.Url }}">
|
||||||
{{~ # Image }}
|
{{ # Image }}
|
||||||
{{ if attachment.IsImage }}
|
{{~ if attachment.IsImage ~}}
|
||||||
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
|
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
|
||||||
{{~ # Non-image }}
|
{{~ # Non-image ~}}
|
||||||
{{ else }}
|
{{~ else ~}}
|
||||||
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
|
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Embeds }}
|
{{~ # Embeds ~}}
|
||||||
{{ for embed in message.Embeds }}
|
{{~ for embed in message.Embeds ~}}
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color | FormatColor }})"></div>
|
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color | FormatColor }})"></div>
|
||||||
<div class="chatlog__embed-content-container">
|
<div class="chatlog__embed-content-container">
|
||||||
<div class="chatlog__embed-content">
|
<div class="chatlog__embed-content">
|
||||||
<div class="chatlog__embed-text">
|
<div class="chatlog__embed-text">
|
||||||
{{~ # Author }}
|
{{~ # Author ~}}
|
||||||
{{ if embed.Author }}
|
{{~ if embed.Author ~}}
|
||||||
<div class="chatlog__embed-author">
|
<div class="chatlog__embed-author">
|
||||||
{{ if embed.Author.IconUrl }}
|
{{~ if embed.Author.IconUrl ~}}
|
||||||
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
|
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{ if embed.Author.Name }}
|
{{~ if embed.Author.Name ~}}
|
||||||
<span class="chatlog__embed-author-name">
|
<span class="chatlog__embed-author-name">
|
||||||
{{ if embed.Author.Url }}
|
{{~ if embed.Author.Url ~}}
|
||||||
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | HtmlEncode }}</a>
|
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
|
||||||
{{ else }}
|
{{~ else ~}}
|
||||||
{{ embed.Author.Name | HtmlEncode }}
|
{{ embed.Author.Name | html.escape }}
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</span>
|
</span>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Title }}
|
{{~ # Title ~}}
|
||||||
{{ if embed.Title }}
|
{{~ if embed.Title ~}}
|
||||||
<div class="chatlog__embed-title">
|
<div class="chatlog__embed-title">
|
||||||
{{ if embed.Url }}
|
{{~ if embed.Url ~}}
|
||||||
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
|
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
|
||||||
{{ else }}
|
{{~ else ~}}
|
||||||
{{ embed.Title | FormatContent }}
|
{{ embed.Title | FormatContent }}
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Description }}
|
{{~ # Description ~}}
|
||||||
{{ if embed.Description }}
|
{{~ if embed.Description ~}}
|
||||||
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
|
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Fields }}
|
{{~ # Fields ~}}
|
||||||
<div class="chatlog__embed-fields">
|
{{~ if embed.Fields | array.size > 0 ~}}
|
||||||
{{ for field in embed.Fields }}
|
<div class="chatlog__embed-fields">
|
||||||
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
{{~ for field in embed.Fields ~}}
|
||||||
{{ if field.Name }}
|
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
||||||
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
|
{{~ if field.Name ~}}
|
||||||
{{ end }}
|
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
|
||||||
{{ if field.Value }}
|
{{~ end ~}}
|
||||||
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
|
{{~ if field.Value ~}}
|
||||||
{{ end }}
|
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
|
||||||
</div>
|
{{~ end ~}}
|
||||||
{{ end }}
|
</div>
|
||||||
</div>
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{~ # Thumbnail }}
|
{{~ # Thumbnail ~}}
|
||||||
{{ if embed.Thumbnail }}
|
{{~ if embed.Thumbnail ~}}
|
||||||
<div class="chatlog__embed-thumbnail-container">
|
<div class="chatlog__embed-thumbnail-container">
|
||||||
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
|
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
|
||||||
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
|
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{~ # Image }}
|
{{~ # Image ~}}
|
||||||
{{ if embed.Image }}
|
{{~ if embed.Image ~}}
|
||||||
<div class="chatlog__embed-image-container">
|
<div class="chatlog__embed-image-container">
|
||||||
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
|
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
|
||||||
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
|
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Footer }}
|
{{~ # Footer ~}}
|
||||||
{{ if embed.Footer || embed.Timestamp }}
|
{{~ if embed.Footer || embed.Timestamp ~}}
|
||||||
<div class="chatlog__embed-footer">
|
<div class="chatlog__embed-footer">
|
||||||
{{ if embed.Footer }}
|
{{~ if embed.Footer ~}}
|
||||||
{{ if embed.Footer.Text && embed.Footer.IconUrl }}
|
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
|
||||||
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
|
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
<span class="chatlog__embed-footer-text">
|
<span class="chatlog__embed-footer-text">
|
||||||
{{ if embed.Footer }}
|
{{~ if embed.Footer ~}}
|
||||||
{{ if embed.Footer.Text }}
|
{{~ if embed.Footer.Text ~}}
|
||||||
{{ embed.Footer.Text | HtmlEncode }}
|
{{ embed.Footer.Text | html.escape }}
|
||||||
{{ if embed.Timestamp }} • {{ end }}
|
{{ if embed.Timestamp }} • {{ end }}
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{ if embed.Timestamp }}
|
{{~ if embed.Timestamp ~}}
|
||||||
{{ embed.Timestamp | FormatDate | HtmlEncode }}
|
{{ embed.Timestamp | FormatDate | html.escape }}
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Reactions }}
|
{{~ # Reactions ~}}
|
||||||
<div class="chatlog__reactions">
|
{{~ if message.Reactions | array.size > 0 ~}}
|
||||||
{{ for reaction in message.Reactions }}
|
<div class="chatlog__reactions">
|
||||||
<div class="chatlog__reaction">
|
{{~ for reaction in message.Reactions ~}}
|
||||||
<span class="chatlog__reaction-emoji">
|
<div class="chatlog__reaction">
|
||||||
{{~ # Custom emoji }}
|
<span class="chatlog__reaction-emoji">
|
||||||
{{ if reaction.EmojiId }}
|
{{~ # Custom emoji ~}}
|
||||||
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
|
{{~ if reaction.EmojiId ~}}
|
||||||
{{~ # Default emoji }}
|
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
|
||||||
{{ else }}
|
{{~ # Default emoji ~}}
|
||||||
{{ reaction.EmojiName }}
|
{{~ else ~}}
|
||||||
{{ end }}
|
{{ reaction.EmojiName }}
|
||||||
</span>
|
{{~ end ~}}
|
||||||
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
|
</span>
|
||||||
</div>
|
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
|
||||||
{{ end }}
|
</div>
|
||||||
</div>
|
{{~ end ~}}
|
||||||
{{ end }}
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
==============================================================
|
{{~ # Info ~}}
|
||||||
Guild: {{ Guild.Name }}
|
==============================================================
|
||||||
Channel: {{ Channel.Name }}
|
Guild: {{ Model.Guild.Name }}
|
||||||
Topic: {{ Channel.Topic }}
|
Channel: {{ Model.Channel.Name }}
|
||||||
Messages: {{ TotalMessageCount | Format "N0" }}
|
Topic: {{ Model.Channel.Topic }}
|
||||||
Range: {{ if From }}{{ From | FormatDate }} {{ end }}{{ if From || To }}->{{ end }}{{ if To }} {{ To | FormatDate }}{{ end }}
|
Messages: {{ Model.TotalMessageCount | Format "N0" }}
|
||||||
|
Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model.From || Model.To }}->{{ end }}{{ if Model.To }} {{ Model.To | FormatDate }}{{ end }}
|
||||||
==============================================================
|
==============================================================
|
||||||
|
|
||||||
{{~ for group in MessageGroups ~}}
|
{{~ # Log ~}}
|
||||||
{{~ group.Author.FullName }} [{{ group.TimeStamp | FormatDate }}]
|
{{~ for group in Model.MessageGroups ~}}
|
||||||
|
{{~ # Author name and timestamp ~}}
|
||||||
|
{{~ }}[{{ group.Timestamp | FormatDate }}] {{ group.Author.FullName }}
|
||||||
|
{{~ # Messages ~}}
|
||||||
{{~ for message in group.Messages ~}}
|
{{~ for message in group.Messages ~}}
|
||||||
|
{{~ # Content ~}}
|
||||||
{{~ message.Content | FormatContent }}
|
{{~ message.Content | FormatContent }}
|
||||||
|
{{~ # Attachments ~}}
|
||||||
{{~ for attachment in message.Attachments ~}}
|
{{~ for attachment in message.Attachments ~}}
|
||||||
{{~ attachment.Url }}
|
{{~ attachment.Url }}
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
|
|
||||||
|
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using DiscordChatExporter.Core.Internal;
|
using DiscordChatExporter.Core.Internal;
|
||||||
using Polly;
|
using Polly;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
namespace DiscordChatExporter.Core.Services
|
||||||
{
|
{
|
||||||
@@ -15,17 +17,9 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient = new HttpClient();
|
private readonly HttpClient _httpClient = new HttpClient();
|
||||||
|
|
||||||
private async Task<JToken> GetApiResponseAsync(string token, string resource, string endpoint,
|
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
|
||||||
params string[] parameters)
|
params string[] parameters)
|
||||||
{
|
{
|
||||||
// Format URL
|
|
||||||
const string apiRoot = "https://discordapp.com/api/v6";
|
|
||||||
var url = $"{apiRoot}/{resource}/{endpoint}?token={token}";
|
|
||||||
|
|
||||||
// Add parameters
|
|
||||||
foreach (var parameter in parameters)
|
|
||||||
url += $"&{parameter}";
|
|
||||||
|
|
||||||
// Create request policy
|
// Create request policy
|
||||||
var policy = Policy
|
var policy = Policy
|
||||||
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
|
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
|
||||||
@@ -34,23 +28,45 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
// Send request
|
// Send request
|
||||||
return await policy.ExecuteAsync(async () =>
|
return await policy.ExecuteAsync(async () =>
|
||||||
{
|
{
|
||||||
using (var response = await _httpClient.GetAsync(url))
|
// Create request
|
||||||
|
const string apiRoot = "https://discordapp.com/api/v6";
|
||||||
|
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
|
||||||
{
|
{
|
||||||
// Check status code
|
// Add url parameter for the user token
|
||||||
// We throw our own exception here because default one doesn't have status code
|
if (token.Type == AuthTokenType.User)
|
||||||
if (!response.IsSuccessStatusCode)
|
request.RequestUri = request.RequestUri.SetQueryParameter("token", token.Value);
|
||||||
throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase);
|
|
||||||
|
|
||||||
// Get content
|
// Add header for the bot token
|
||||||
var raw = await response.Content.ReadAsStringAsync();
|
if (token.Type == AuthTokenType.Bot)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bot", token.Value);
|
||||||
|
|
||||||
// Parse
|
// Add parameters
|
||||||
return JToken.Parse(raw);
|
foreach (var parameter in parameters)
|
||||||
|
{
|
||||||
|
var key = parameter.SubstringUntil("=");
|
||||||
|
var value = parameter.SubstringAfter("=");
|
||||||
|
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
using (var response = await _httpClient.SendAsync(request))
|
||||||
|
{
|
||||||
|
// Check status code
|
||||||
|
// We throw our own exception here because default one doesn't have status code
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase);
|
||||||
|
|
||||||
|
// Get content
|
||||||
|
var raw = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
return JToken.Parse(raw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Guild> GetGuildAsync(string token, string guildId)
|
public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
|
||||||
{
|
{
|
||||||
var response = await GetApiResponseAsync(token, "guilds", guildId);
|
var response = await GetApiResponseAsync(token, "guilds", guildId);
|
||||||
var guild = ParseGuild(response);
|
var guild = ParseGuild(response);
|
||||||
@@ -58,7 +74,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
return guild;
|
return guild;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Channel> GetChannelAsync(string token, string channelId)
|
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
|
||||||
{
|
{
|
||||||
var response = await GetApiResponseAsync(token, "channels", channelId);
|
var response = await GetApiResponseAsync(token, "channels", channelId);
|
||||||
var channel = ParseChannel(response);
|
var channel = ParseChannel(response);
|
||||||
@@ -66,7 +82,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token)
|
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
|
||||||
{
|
{
|
||||||
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
|
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
|
||||||
var guilds = response.Select(ParseGuild).ToArray();
|
var guilds = response.Select(ParseGuild).ToArray();
|
||||||
@@ -74,7 +90,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
return guilds;
|
return guilds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
|
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
|
||||||
{
|
{
|
||||||
var response = await GetApiResponseAsync(token, "users", "@me/channels");
|
var response = await GetApiResponseAsync(token, "users", "@me/channels");
|
||||||
var channels = response.Select(ParseChannel).ToArray();
|
var channels = response.Select(ParseChannel).ToArray();
|
||||||
@@ -82,7 +98,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
|
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
|
||||||
{
|
{
|
||||||
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
|
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
|
||||||
var channels = response.Select(ParseChannel).ToArray();
|
var channels = response.Select(ParseChannel).ToArray();
|
||||||
@@ -90,7 +106,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId)
|
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
|
||||||
{
|
{
|
||||||
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
|
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
|
||||||
var roles = response.Select(ParseRole).ToArray();
|
var roles = response.Select(ParseRole).ToArray();
|
||||||
@@ -98,7 +114,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
return roles;
|
return roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
|
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||||
{
|
{
|
||||||
var result = new List<Message>();
|
var result = new List<Message>();
|
||||||
@@ -169,7 +185,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Mentionables> GetMentionablesAsync(string token, string guildId,
|
public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
||||||
IEnumerable<Message> messages)
|
IEnumerable<Message> messages)
|
||||||
{
|
{
|
||||||
// Get channels and roles
|
// Get channels and roles
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
|
|
||||||
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
|
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
|
||||||
|
|
||||||
private string HtmlDecode(string str) => WebUtility.HtmlDecode(str);
|
|
||||||
|
|
||||||
private string Format(IFormattable obj, string format) =>
|
private string Format(IFormattable obj, string format) =>
|
||||||
obj.ToString(format, CultureInfo.InvariantCulture);
|
obj.ToString(format, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
@@ -309,11 +307,9 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
var scriptObject = new ScriptObject();
|
var scriptObject = new ScriptObject();
|
||||||
|
|
||||||
// Import chat log
|
// Import chat log
|
||||||
scriptObject.Import(_log, TemplateMemberFilter, TemplateMemberRenamer);
|
scriptObject.SetValue("Model", _log, true);
|
||||||
|
|
||||||
// Import functions
|
// 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(Format), new Func<IFormattable, string, string>(Format));
|
||||||
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
|
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
|
||||||
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
|
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
{
|
{
|
||||||
public partial class ExportService : IExportService
|
public partial class ExportService : IExportService
|
||||||
{
|
{
|
||||||
private static readonly MemberRenamerDelegate TemplateMemberRenamer = m => m.Name;
|
|
||||||
private static readonly MemberFilterDelegate TemplateMemberFilter = m => true;
|
|
||||||
|
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
public ExportService(ISettingsService settingsService)
|
public ExportService(ISettingsService settingsService)
|
||||||
@@ -30,8 +27,10 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
var context = new TemplateContext
|
var context = new TemplateContext
|
||||||
{
|
{
|
||||||
TemplateLoader = loader,
|
TemplateLoader = loader,
|
||||||
MemberRenamer = TemplateMemberRenamer,
|
MemberRenamer = m => m.Name,
|
||||||
MemberFilter = TemplateMemberFilter
|
MemberFilter = m => true,
|
||||||
|
LoopLimit = int.MaxValue,
|
||||||
|
StrictVariables = true
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create template model
|
// Create template model
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
{
|
{
|
||||||
public interface IDataService
|
public interface IDataService
|
||||||
{
|
{
|
||||||
Task<Guild> GetGuildAsync(string token, string guildId);
|
Task<Guild> GetGuildAsync(AuthToken token, string guildId);
|
||||||
|
|
||||||
Task<Channel> GetChannelAsync(string token, string channelId);
|
Task<Channel> GetChannelAsync(AuthToken token, string channelId);
|
||||||
|
|
||||||
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token);
|
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
|
||||||
|
|
||||||
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
|
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
|
||||||
|
|
||||||
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
|
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
|
||||||
|
|
||||||
Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId);
|
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
|
||||||
|
|
||||||
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
|
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
||||||
|
|
||||||
Task<Mentionables> GetMentionablesAsync(string token, string guildId,
|
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
||||||
IEnumerable<Message> messages);
|
IEnumerable<Message> messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
string DateFormat { get; set; }
|
string DateFormat { get; set; }
|
||||||
int MessageGroupLimit { get; set; }
|
int MessageGroupLimit { get; set; }
|
||||||
|
|
||||||
string LastToken { get; set; }
|
AuthToken LastToken { get; set; }
|
||||||
ExportFormat LastExportFormat { get; set; }
|
ExportFormat LastExportFormat { get; set; }
|
||||||
|
|
||||||
void Load();
|
void Load();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
public int MessageGroupLimit { get; set; } = 20;
|
public int MessageGroupLimit { get; set; } = 20;
|
||||||
|
|
||||||
public string LastToken { get; set; }
|
public AuthToken LastToken { get; set; }
|
||||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
public SettingsService()
|
public SettingsService()
|
||||||
|
|||||||
@@ -96,6 +96,21 @@
|
|||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style
|
||||||
|
x:Key="MaterialDesignFlatActionToggleButton"
|
||||||
|
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
|
||||||
|
TargetType="{x:Type ToggleButton}">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
|
||||||
|
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Converters -->
|
<!-- Converters -->
|
||||||
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
|
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
[assembly: AssemblyTitle("DiscordChatExporter")]
|
[assembly: AssemblyTitle("DiscordChatExporter")]
|
||||||
[assembly: AssemblyCompany("Tyrrrz")]
|
[assembly: AssemblyCompany("Tyrrrz")]
|
||||||
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
|
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
|
||||||
[assembly: AssemblyVersion("2.5")]
|
[assembly: AssemblyVersion("2.6")]
|
||||||
[assembly: AssemblyFileVersion("2.5")]
|
[assembly: AssemblyFileVersion("2.6")]
|
||||||
@@ -34,7 +34,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<ExportFormat> AvailableFormats { get; }
|
public IReadOnlyList<ExportFormat> AvailableFormats =>
|
||||||
|
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
|
||||||
|
|
||||||
public ExportFormat SelectedFormat
|
public ExportFormat SelectedFormat
|
||||||
{
|
{
|
||||||
@@ -69,9 +70,6 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
{
|
{
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
|
|
||||||
// Defaults
|
|
||||||
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
|
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
|
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
bool IsProgressIndeterminate { get; }
|
bool IsProgressIndeterminate { get; }
|
||||||
double Progress { get; }
|
double Progress { get; }
|
||||||
|
|
||||||
string Token { get; set; }
|
bool IsBotToken { get; set; }
|
||||||
|
string TokenValue { get; set; }
|
||||||
|
|
||||||
IReadOnlyList<Guild> AvailableGuilds { get; }
|
IReadOnlyList<Guild> AvailableGuilds { get; }
|
||||||
Guild SelectedGuild { get; set; }
|
Guild SelectedGuild { get; set; }
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
|
|
||||||
private bool _isBusy;
|
private bool _isBusy;
|
||||||
private double _progress;
|
private double _progress;
|
||||||
private string _token;
|
private bool _isBotToken;
|
||||||
|
private string _tokenValue;
|
||||||
private IReadOnlyList<Guild> _availableGuilds;
|
private IReadOnlyList<Guild> _availableGuilds;
|
||||||
private Guild _selectedGuild;
|
private Guild _selectedGuild;
|
||||||
private IReadOnlyList<Channel> _availableChannels;
|
private IReadOnlyList<Channel> _availableChannels;
|
||||||
@@ -56,15 +57,21 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Token
|
public bool IsBotToken
|
||||||
{
|
{
|
||||||
get => _token;
|
get => _isBotToken;
|
||||||
|
set => Set(ref _isBotToken, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TokenValue
|
||||||
|
{
|
||||||
|
get => _tokenValue;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
// Remove invalid chars
|
// Remove invalid chars
|
||||||
value = value?.Trim('"');
|
value = value?.Trim('"');
|
||||||
|
|
||||||
Set(ref _token, value);
|
Set(ref _tokenValue, value);
|
||||||
PullDataCommand.RaiseCanExecuteChanged();
|
PullDataCommand.RaiseCanExecuteChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +124,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
// Commands
|
// Commands
|
||||||
ViewLoadedCommand = new RelayCommand(ViewLoaded);
|
ViewLoadedCommand = new RelayCommand(ViewLoaded);
|
||||||
ViewClosedCommand = new RelayCommand(ViewClosed);
|
ViewClosedCommand = new RelayCommand(ViewClosed);
|
||||||
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
|
PullDataCommand = new RelayCommand(PullData, () => TokenValue.IsNotBlank() && !IsBusy);
|
||||||
ShowSettingsCommand = new RelayCommand(ShowSettings);
|
ShowSettingsCommand = new RelayCommand(ShowSettings);
|
||||||
ShowAboutCommand = new RelayCommand(ShowAbout);
|
ShowAboutCommand = new RelayCommand(ShowAbout);
|
||||||
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
|
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
|
||||||
@@ -132,8 +139,12 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
// Load settings
|
// Load settings
|
||||||
_settingsService.Load();
|
_settingsService.Load();
|
||||||
|
|
||||||
// Set last token
|
// Get last token
|
||||||
Token = _settingsService.LastToken;
|
if (_settingsService.LastToken != null)
|
||||||
|
{
|
||||||
|
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
|
||||||
|
TokenValue = _settingsService.LastToken.Value;
|
||||||
|
}
|
||||||
|
|
||||||
// Check and prepare update
|
// Check and prepare update
|
||||||
try
|
try
|
||||||
@@ -169,8 +180,10 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
|
|
||||||
// Copy token so it doesn't get mutated
|
// Create token
|
||||||
var token = Token;
|
var token = new AuthToken(
|
||||||
|
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
|
||||||
|
TokenValue);
|
||||||
|
|
||||||
// Save token
|
// Save token
|
||||||
_settingsService.LastToken = token;
|
_settingsService.LastToken = token;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Height="550"
|
Height="550"
|
||||||
Background="{DynamicResource MaterialDesignPaper}"
|
Background="{DynamicResource MaterialDesignPaper}"
|
||||||
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
|
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
|
||||||
FocusManager.FocusedElement="{Binding ElementName=TokenTextBox}"
|
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
|
||||||
FontFamily="{DynamicResource MaterialDesignFont}"
|
FontFamily="{DynamicResource MaterialDesignFont}"
|
||||||
Icon="/DiscordChatExporter;component/favicon.ico"
|
Icon="/DiscordChatExporter;component/favicon.ico"
|
||||||
SnapsToDevicePixels="True"
|
SnapsToDevicePixels="True"
|
||||||
@@ -48,27 +48,47 @@
|
|||||||
Margin="6,6,0,6">
|
Margin="6,6,0,6">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Token -->
|
<!-- Token type -->
|
||||||
<TextBox
|
<ToggleButton
|
||||||
x:Name="TokenTextBox"
|
|
||||||
Grid.Row="0"
|
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="6"
|
Margin="6"
|
||||||
|
IsChecked="{Binding IsBotToken}"
|
||||||
|
Style="{StaticResource MaterialDesignFlatActionToggleButton}"
|
||||||
|
ToolTip="Switch between user token and bot token">
|
||||||
|
<ToggleButton.Content>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="Account" />
|
||||||
|
</ToggleButton.Content>
|
||||||
|
<materialDesign:ToggleButtonAssist.OnContent>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="Robot" />
|
||||||
|
</materialDesign:ToggleButtonAssist.OnContent>
|
||||||
|
</ToggleButton>
|
||||||
|
|
||||||
|
<!-- Token value -->
|
||||||
|
<TextBox
|
||||||
|
x:Name="TokenValueTextBox"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="2,6,6,7"
|
||||||
materialDesign:HintAssist.Hint="Token"
|
materialDesign:HintAssist.Hint="Token"
|
||||||
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||||
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
|
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
|
||||||
<!-- Pull data button -->
|
<!-- Pull data button -->
|
||||||
<Button
|
<Button
|
||||||
Grid.Row="0"
|
Grid.Column="2"
|
||||||
Grid.Column="1"
|
|
||||||
Margin="0,6,6,6"
|
Margin="0,6,6,6"
|
||||||
Padding="4"
|
Padding="4"
|
||||||
Command="{Binding PullDataCommand}"
|
Command="{Binding PullDataCommand}"
|
||||||
@@ -99,8 +119,8 @@
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||||
Value="{Binding Progress, Mode=OneWay}"
|
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
|
||||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Value="{Binding Progress, Mode=OneWay}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -196,34 +216,65 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Usage instructions -->
|
<!-- Usage instructions -->
|
||||||
<StackPanel Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
<Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||||
<TextBlock FontSize="18" Text="DiscordChatExporter needs your authorization token to work." />
|
<!-- User token -->
|
||||||
<TextBlock
|
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||||
Margin="0,8,0,0"
|
<TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work." />
|
||||||
FontSize="16"
|
<TextBlock
|
||||||
Text="To obtain it, follow these steps:" />
|
Margin="0,8,0,0"
|
||||||
<TextBlock Margin="8,0,0,0" FontSize="14">
|
FontSize="16"
|
||||||
<Run Text="1. Open the Discord app" />
|
Text="To obtain it, follow these steps:" />
|
||||||
<LineBreak />
|
<TextBlock Margin="8,0,0,0" FontSize="14">
|
||||||
<Run Text="2. Log in if you haven't" />
|
<Run Text="1. Open the Discord app" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="3. Press" />
|
<Run Text="2. Log in if you haven't" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
<LineBreak />
|
||||||
<LineBreak />
|
<Run Text="3. Press" />
|
||||||
<Run Text="4. Navigate to" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
<Run Text="to show developer tools" />
|
||||||
<Run Text="tab" />
|
<LineBreak />
|
||||||
<LineBreak />
|
<Run Text="4. Navigate to the" />
|
||||||
<Run Text="5. Expand" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage > Local Storage > https://discordapp.com" />
|
<Run Text="tab" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="6. Find" />
|
<Run Text="5. Expand" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text=""token"" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage > Local Storage > https://discordapp.com" />
|
||||||
<Run Text="under key and copy the value" />
|
<LineBreak />
|
||||||
<LineBreak />
|
<Run Text="6. Find the" />
|
||||||
<Run Text="7. Paste the value in the textbox above" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text=""token"" />
|
||||||
</TextBlock>
|
<Run Text="key and copy its value" />
|
||||||
</StackPanel>
|
<LineBreak />
|
||||||
|
<Run Text="7. Paste the value in the textbox above" />
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Bot token -->
|
||||||
|
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||||
|
<TextBlock FontSize="18" Text="DiscordChatExporter needs your bot token to work." />
|
||||||
|
<TextBlock
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
FontSize="16"
|
||||||
|
Text="To obtain it, follow these steps:" />
|
||||||
|
<TextBlock Margin="8,0,0,0" FontSize="14">
|
||||||
|
<Run Text="1. Go to Discord developer portal" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="2. Log in if you haven't" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="3. Open your application's settings" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="4. Navigate to the" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
|
||||||
|
<Run Text="section on the left" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="5. Under" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
|
||||||
|
<Run Text="click" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="6. Paste the value in the textbox above" />
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
<materialDesign:Snackbar x:Name="Snackbar" />
|
<materialDesign:Snackbar x:Name="Snackbar" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -18,22 +18,11 @@ DiscordChatExporter can be used to export message history from a [Discord](https
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Intuitive GUI that displays available guilds and channels
|
- Graphical and command line interfaces
|
||||||
- CLI as additional alternative to GUI
|
- Supports both user tokens and bot tokens
|
||||||
- Date ranges to limit messages
|
- Allows retrieving messages in specified date range
|
||||||
- Groups messages by author and time
|
- Multiple export formats: HTML (dark/light), TXT and CSV
|
||||||
- Exports to a plain text file
|
- Renders all message features including: markdown, attachments, embeds, emojis, mentions, etc
|
||||||
- Exports to an HTML file
|
|
||||||
- Dark and light themes
|
|
||||||
- User avatars
|
|
||||||
- Inline image attachments
|
|
||||||
- Embeds and webhooks
|
|
||||||
- Full markdown support
|
|
||||||
- Automatic links
|
|
||||||
- Styled similarly to the app
|
|
||||||
- Exports to a CSV file
|
|
||||||
- Renders custom emojis
|
|
||||||
- Resolves user, role and channel mentions
|
|
||||||
|
|
||||||
## Libraries used
|
## Libraries used
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user