Self-contained export (#321)

This commit is contained in:
Alexey Golub
2020-07-18 15:45:09 +03:00
committed by GitHub
parent 94a85cdb01
commit ac64d9943a
56 changed files with 813 additions and 581 deletions

View File

@@ -16,9 +16,11 @@ namespace DiscordChatExporter.Domain.Discord
Value = value;
}
public AuthenticationHeaderValue GetAuthorizationHeader() => Type == AuthTokenType.User
? new AuthenticationHeaderValue(Value)
: new AuthenticationHeaderValue("Bot", Value);
public AuthenticationHeaderValue GetAuthorizationHeader() => Type switch
{
AuthTokenType.Bot => new AuthenticationHeaderValue("Bot", Value),
_ => new AuthenticationHeaderValue(Value)
};
public override string ToString() => Value;
}

View File

@@ -8,25 +8,26 @@ using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
using Polly;
namespace DiscordChatExporter.Domain.Discord
{
public partial class DiscordClient
public class DiscordClient
{
private readonly AuthToken _token;
private readonly HttpClient _httpClient;
private readonly HttpClient _httpClient = Singleton.HttpClient;
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
private readonly Uri _baseUri = new Uri("https://discordapp.com/api/v6/", UriKind.Absolute);
public DiscordClient(AuthToken token, HttpClient httpClient)
public DiscordClient(AuthToken token)
{
_token = token;
_httpClient = httpClient;
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
// For that reason the policy will start respecting their retry-after header only after Nth failed response.
// For that reason the policy will ignore such errors at first, then wait a constant amount of time, and
// finally wait the specified amount of time, based on how many requests have failed in a row.
_httpRequestPolicy = Policy
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
@@ -41,24 +42,17 @@ namespace DiscordChatExporter.Domain.Discord
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
},
(response, timespan, retryCount, context) => Task.CompletedTask);
(response, timespan, retryCount, context) => Task.CompletedTask
);
}
public DiscordClient(AuthToken token)
: this(token, LazyHttpClient.Value)
private async Task<HttpResponseMessage> GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () =>
{
}
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthorizationHeader();
private async Task<HttpResponseMessage> GetResponseAsync(string url)
{
return await _httpRequestPolicy.ExecuteAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthorizationHeader();
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
});
}
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
});
private async Task<JsonElement> GetJsonResponseAsync(string url)
{
@@ -97,15 +91,14 @@ namespace DiscordChatExporter.Domain.Discord
var url = new UrlBuilder()
.SetPath("users/@me/guilds")
.SetQueryParameter("limit", "100")
.SetQueryParameterIfNotNullOrWhiteSpace("after", afterId)
.SetQueryParameter("after", afterId)
.Build();
var response = await GetJsonResponseAsync(url);
var isEmpty = true;
foreach (var guildJson in response.EnumerateArray())
foreach (var guild in response.EnumerateArray().Select(Guild.Parse))
{
var guild = Guild.Parse(guildJson);
yield return guild;
afterId = guild.Id;
@@ -206,7 +199,7 @@ namespace DiscordChatExporter.Domain.Discord
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "1")
.SetQueryParameterIfNotNullOrWhiteSpace("before", before?.ToSnowflake())
.SetQueryParameter("before", before?.ToSnowflake())
.Build();
var response = await GetJsonResponseAsync(url);
@@ -219,11 +212,15 @@ namespace DiscordChatExporter.Domain.Discord
DateTimeOffset? before = null,
IProgress<double>? progress = null)
{
// Get the last message in the specified range
// Get the last message in the specified range.
// This snapshots the boundaries, which means that messages posted after the exported started
// will not appear in the output.
// Additionally, it provides the date of the last message, which is used to calculate progress.
var lastMessage = await TryGetLastMessageAsync(channelId, before);
if (lastMessage == null || lastMessage.Timestamp < after)
yield break;
// Keep track of first message in range in order to calculate progress
var firstMessage = default(Message);
var afterId = after?.ToSnowflake() ?? "0";
@@ -267,19 +264,4 @@ namespace DiscordChatExporter.Domain.Discord
}
}
}
public partial class DiscordClient
{
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
{
var handler = new HttpClientHandler();
if (handler.SupportsAutomaticDecompression)
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
handler.UseCookies = false;
return new HttpClient(handler, true);
});
}
}

View File

@@ -3,7 +3,7 @@ using System.IO;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -1,7 +1,7 @@
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -1,5 +1,5 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -1,5 +1,5 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -1,5 +1,5 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -1,5 +1,5 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -2,7 +2,7 @@
using System.Linq;
using System.Text;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models

View File

@@ -12,12 +12,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
public string IconUrl { get; }
public Guild(string id, string name, string? iconHash)
public Guild(string id, string name, string iconUrl)
{
Id = id;
Name = name;
IconUrl = GetIconUrl(id, iconHash);
IconUrl = iconUrl;
}
public override string ToString() => Name;
@@ -26,12 +25,13 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Guild
{
public static Guild DirectMessages { get; } =
new Guild("@me", "Direct Messages", null);
new Guild("@me", "Direct Messages", GetDefaultIconUrl());
private static string GetIconUrl(string id, string? iconHash) =>
!string.IsNullOrWhiteSpace(iconHash)
? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
private static string GetDefaultIconUrl() =>
"https://cdn.discordapp.com/embed/avatars/0.png";
private static string GetIconUrl(string id, string iconHash) =>
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
public static Guild Parse(JsonElement json)
{
@@ -39,7 +39,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
var name = json.GetProperty("name").GetString();
var iconHash = json.GetProperty("icon").GetString();
return new Guild(id, name, iconHash);
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
? GetIconUrl(id, iconHash)
: GetDefaultIconUrl();
return new Guild(id, name, iconUrl);
}
}
}

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{
@@ -71,10 +71,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
MentionedUsers = mentionedUsers;
}
public override string ToString() =>
Content ?? (Embeds.Any()
? "<embed>"
: "<no content>");
public override string ToString() => Content;
}
public partial class Message

View File

@@ -1,5 +1,5 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -1,6 +1,6 @@
using System.Drawing;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -1,7 +1,7 @@
using System;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{
@@ -20,14 +20,13 @@ namespace DiscordChatExporter.Domain.Discord.Models
public string AvatarUrl { get; }
public User(string id, bool isBot, int discriminator, string name, string? avatarHash)
public User(string id, bool isBot, int discriminator, string name, string avatarUrl)
{
Id = id;
IsBot = isBot;
Discriminator = discriminator;
Name = name;
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
AvatarUrl = avatarUrl;
}
public override string ToString() => FullName;
@@ -35,21 +34,17 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class User
{
private static string GetAvatarUrl(string id, int discriminator, string? avatarHash)
private static string GetDefaultAvatarUrl(int discriminator) =>
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
private static string GetAvatarUrl(string id, string avatarHash)
{
// Custom avatar
if (!string.IsNullOrWhiteSpace(avatarHash))
{
// Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
// Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
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/{discriminator % 5}.png";
// Non-animated
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
}
public static User Parse(JsonElement json)
@@ -60,7 +55,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
var avatarHash = json.GetProperty("avatar").GetString();
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
return new User(id, isBot, discriminator, name, avatarHash);
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
? GetAvatarUrl(id, avatarHash)
: GetDefaultAvatarUrl(discriminator);
return new User(id, isBot, discriminator, name, avatarUrl);
}
}
}

View File

@@ -1,48 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
namespace DiscordChatExporter.Domain.Discord
{
internal class UrlBuilder
{
private string _path = "";
private readonly Dictionary<string, string?> _queryParameters =
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
public UrlBuilder SetPath(string path)
{
_path = path;
return this;
}
public UrlBuilder SetQueryParameter(string key, string? value)
{
var keyEncoded = WebUtility.UrlEncode(key);
var valueEncoded = WebUtility.UrlEncode(value);
_queryParameters[keyEncoded] = valueEncoded;
return this;
}
public UrlBuilder SetQueryParameterIfNotNullOrWhiteSpace(string key, string? value) =>
!string.IsNullOrWhiteSpace(value)
? SetQueryParameter(key, value)
: this;
public string Build()
{
var buffer = new StringBuilder();
buffer.Append(_path);
if (_queryParameters.Any())
buffer.Append('?').AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
return buffer.ToString();
}
}
}