pull/613/head
Tyrrrz 3 years ago
parent 9491e18e2f
commit 29552be6e5

@ -2,8 +2,6 @@
namespace DiscordChatExporter.Core.Discord namespace DiscordChatExporter.Core.Discord
{ {
public enum AuthTokenType { User, Bot }
public class AuthToken public class AuthToken
{ {
public AuthTokenType Type { get; } public AuthTokenType Type { get; }

@ -0,0 +1,8 @@
namespace DiscordChatExporter.Core.Discord
{
public enum AuthTokenType
{
User,
Bot
}
}

@ -7,32 +7,19 @@ using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Discord.Data namespace DiscordChatExporter.Core.Discord.Data
{ {
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
// Order of enum fields needs to match the order in the docs.
public enum ChannelType
{
GuildTextChat,
DirectTextChat,
GuildVoiceChat,
DirectGroupTextChat,
GuildCategory,
GuildNews,
GuildStore
}
// https://discord.com/developers/docs/resources/channel#channel-object // https://discord.com/developers/docs/resources/channel#channel-object
public partial class Channel : IHasId, IHasPosition public partial class Channel : IHasId
{ {
public Snowflake Id { get; } public Snowflake Id { get; }
public ChannelType Type { get; } public ChannelType Type { get; }
public bool IsTextChannel => public bool IsTextChannel => Type is
Type == ChannelType.GuildTextChat || ChannelType.GuildTextChat or
Type == ChannelType.DirectTextChat || ChannelType.DirectTextChat or
Type == ChannelType.DirectGroupTextChat || ChannelType.DirectGroupTextChat or
Type == ChannelType.GuildNews || ChannelType.GuildNews or
Type == ChannelType.GuildStore; ChannelType.GuildStore;
public Snowflake GuildId { get; } public Snowflake GuildId { get; }
@ -48,7 +35,7 @@ namespace DiscordChatExporter.Core.Discord.Data
Snowflake id, Snowflake id,
ChannelType type, ChannelType type,
Snowflake guildId, Snowflake guildId,
ChannelCategory? category, ChannelCategory category,
string name, string name,
int? position, int? position,
string? topic) string? topic)
@ -56,14 +43,13 @@ namespace DiscordChatExporter.Core.Discord.Data
Id = id; Id = id;
Type = type; Type = type;
GuildId = guildId; GuildId = guildId;
Category = category ?? GetFallbackCategory(type); Category = category;
Name = name; Name = name;
Position = position; Position = position;
Topic = topic; Topic = topic;
} }
public override string ToString() => Name; public override string ToString() => Name;
} }
public partial class Channel public partial class Channel
@ -79,7 +65,7 @@ namespace DiscordChatExporter.Core.Discord.Data
ChannelType.GuildStore => "Store", ChannelType.GuildStore => "Store",
_ => "Default" _ => "Default"
}, },
0 null
); );
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? position = null) public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? position = null)
@ -87,23 +73,23 @@ namespace DiscordChatExporter.Core.Discord.Data
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse); var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse);
var topic = json.GetPropertyOrNull("topic")?.GetString(); var topic = json.GetPropertyOrNull("topic")?.GetString();
var type = (ChannelType) json.GetProperty("type").GetInt32(); var type = (ChannelType) json.GetProperty("type").GetInt32();
var name = var name =
// Guild channel
json.GetPropertyOrNull("name")?.GetString() ?? json.GetPropertyOrNull("name")?.GetString() ??
// DM channel
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ?? json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
// Fallback
id.ToString(); id.ToString();
position ??= json.GetPropertyOrNull("position")?.GetInt32();
return new Channel( return new Channel(
id, id,
type, type,
guildId ?? Guild.DirectMessages.Id, guildId ?? Guild.DirectMessages.Id,
category ?? GetFallbackCategory(type), category ?? GetFallbackCategory(type),
name, name,
position, position ?? json.GetPropertyOrNull("position")?.GetInt32(),
topic topic
); );
} }

@ -1,13 +1,11 @@
using System.Linq; using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Discord.Data namespace DiscordChatExporter.Core.Discord.Data
{ {
public partial class ChannelCategory : IHasId, IHasPosition public partial class ChannelCategory : IHasId
{ {
public Snowflake Id { get; } public Snowflake Id { get; }
@ -23,7 +21,6 @@ namespace DiscordChatExporter.Core.Discord.Data
} }
public override string ToString() => Name; public override string ToString() => Name;
} }
public partial class ChannelCategory public partial class ChannelCategory
@ -31,19 +28,18 @@ namespace DiscordChatExporter.Core.Discord.Data
public static ChannelCategory Parse(JsonElement json, int? position = null) public static ChannelCategory Parse(JsonElement json, int? position = null)
{ {
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
position ??= json.GetPropertyOrNull("position")?.GetInt32();
var name = json.GetPropertyOrNull("name")?.GetString() ?? var name =
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ?? json.GetPropertyOrNull("name")?.GetString() ??
id.ToString(); id.ToString();
return new ChannelCategory( return new ChannelCategory(
id, id,
name, name,
position position ?? json.GetPropertyOrNull("position")?.GetInt32()
); );
} }
public static ChannelCategory Empty { get; } = new(Snowflake.Zero, "<unknown category>", 0); public static ChannelCategory Empty { get; } = new(Snowflake.Zero, "<unknown category>", 0);
} }
} }

@ -0,0 +1,15 @@
namespace DiscordChatExporter.Core.Discord.Data
{
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
// Order of enum fields needs to match the order in the docs.
public enum ChannelType
{
GuildTextChat = 0,
DirectTextChat,
GuildVoiceChat,
DirectGroupTextChat,
GuildCategory,
GuildNews,
GuildStore
}
}

@ -1,7 +0,0 @@
namespace DiscordChatExporter.Core.Discord.Data.Common
{
public interface IHasPosition
{
int? Position { get; }
}
}

@ -63,10 +63,10 @@ namespace DiscordChatExporter.Core.Discord.Data
public static Embed Parse(JsonElement json) public static Embed Parse(JsonElement json)
{ {
var title = json.GetPropertyOrNull("title")?.GetString(); var title = json.GetPropertyOrNull("title")?.GetString();
var description = json.GetPropertyOrNull("description")?.GetString();
var url = json.GetPropertyOrNull("url")?.GetString(); var url = json.GetPropertyOrNull("url")?.GetString();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset(); var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(System.Drawing.Color.FromArgb).ResetAlpha(); var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
var description = json.GetPropertyOrNull("description")?.GetString();
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse); var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);

@ -18,13 +18,12 @@ namespace DiscordChatExporter.Core.Discord.Data
public string ImageUrl { get; } public string ImageUrl { get; }
public Emoji(string? id, string name, bool isAnimated) public Emoji(string? id, string name, bool isAnimated, string imageUrl)
{ {
Id = id; Id = id;
Name = name; Name = name;
IsAnimated = isAnimated; IsAnimated = isAnimated;
ImageUrl = imageUrl;
ImageUrl = GetImageUrl(id, name, isAnimated);
} }
public override string ToString() => Name; public override string ToString() => Name;
@ -53,12 +52,9 @@ namespace DiscordChatExporter.Core.Discord.Data
// Custom emoji // Custom emoji
if (!string.IsNullOrWhiteSpace(id)) if (!string.IsNullOrWhiteSpace(id))
{ {
// Animated return isAnimated
if (isAnimated) ? $"https://cdn.discordapp.com/emojis/{id}.gif"
return $"https://cdn.discordapp.com/emojis/{id}.gif"; : $"https://cdn.discordapp.com/emojis/{id}.png";
// Non-animated
return $"https://cdn.discordapp.com/emojis/{id}.png";
} }
// Standard emoji // Standard emoji
@ -73,7 +69,9 @@ namespace DiscordChatExporter.Core.Discord.Data
var name = json.GetProperty("name").GetString(); var name = json.GetProperty("name").GetString();
var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false; var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false;
return new Emoji(id, name, isAnimated); var imageUrl = GetImageUrl(id, name, isAnimated);
return new Emoji(id, name, isAnimated, imageUrl);
} }
} }
} }

@ -19,10 +19,10 @@ namespace DiscordChatExporter.Core.Discord.Data
public IReadOnlyList<Snowflake> RoleIds { get; } public IReadOnlyList<Snowflake> RoleIds { get; }
public Member(User user, string? nick, IReadOnlyList<Snowflake> roleIds) public Member(User user, string nick, IReadOnlyList<Snowflake> roleIds)
{ {
User = user; User = user;
Nick = nick ?? user.Name; Nick = nick;
RoleIds = roleIds; RoleIds = roleIds;
} }
@ -33,7 +33,7 @@ namespace DiscordChatExporter.Core.Discord.Data
{ {
public static Member CreateForUser(User user) => new( public static Member CreateForUser(User user) => new(
user, user,
null, user.Name,
Array.Empty<Snowflake>() Array.Empty<Snowflake>()
); );
@ -43,12 +43,12 @@ namespace DiscordChatExporter.Core.Discord.Data
var nick = json.GetPropertyOrNull("nick")?.GetString(); var nick = json.GetPropertyOrNull("nick")?.GetString();
var roleIds = var roleIds =
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString().Pipe(Snowflake.Parse)).ToArray() ?? json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).Select(Snowflake.Parse).ToArray() ??
Array.Empty<Snowflake>(); Array.Empty<Snowflake>();
return new Member( return new Member(
user, user,
nick, nick ?? user.Name,
roleIds roleIds
); );
} }

@ -100,7 +100,7 @@ namespace DiscordChatExporter.Core.Discord.Data
var type = (MessageType) json.GetProperty("type").GetInt32(); var type = (MessageType) json.GetProperty("type").GetInt32();
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false; var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse); var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Message.Parse); var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
var content = type switch var content = type switch
{ {

@ -23,8 +23,8 @@ namespace DiscordChatExporter.Core.Discord.Data
{ {
public static Reaction Parse(JsonElement json) public static Reaction Parse(JsonElement json)
{ {
var count = json.GetProperty("count").GetInt32();
var emoji = json.GetProperty("emoji").Pipe(Emoji.Parse); var emoji = json.GetProperty("emoji").Pipe(Emoji.Parse);
var count = json.GetProperty("count").GetInt32();
return new Reaction(emoji, count); return new Reaction(emoji, count);
} }

@ -40,7 +40,8 @@ namespace DiscordChatExporter.Core.Discord.Data
var name = json.GetProperty("name").GetString(); var name = json.GetProperty("name").GetString();
var position = json.GetProperty("position").GetInt32(); var position = json.GetProperty("position").GetInt32();
var color = json.GetPropertyOrNull("color")? var color = json
.GetPropertyOrNull("color")?
.GetInt32() .GetInt32()
.Pipe(System.Drawing.Color.FromArgb) .Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha() .ResetAlpha()

@ -58,10 +58,10 @@ namespace DiscordChatExporter.Core.Discord.Data
public static User Parse(JsonElement json) public static User Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse); var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
var name = json.GetProperty("username").GetString(); var name = json.GetProperty("username").GetString();
var avatarHash = json.GetProperty("avatar").GetString(); var avatarHash = json.GetProperty("avatar").GetString();
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash) var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
? GetAvatarUrl(id, avatarHash) ? GetAvatarUrl(id, avatarHash)

@ -11,7 +11,6 @@ using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Http; using JsonExtensions.Http;
using JsonExtensions.Reading; using JsonExtensions.Reading;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Discord namespace DiscordChatExporter.Core.Discord
{ {
@ -29,7 +28,9 @@ namespace DiscordChatExporter.Core.Discord
} }
public DiscordClient(AuthToken token) public DiscordClient(AuthToken token)
: this(Http.Client, token) {} : this(Http.Client, token)
{
}
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) => private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) =>
await Http.ResponsePolicy.ExecuteAsync(async () => await Http.ResponsePolicy.ExecuteAsync(async () =>
@ -64,7 +65,7 @@ namespace DiscordChatExporter.Core.Discord
return response.IsSuccessStatusCode return response.IsSuccessStatusCode
? await response.Content.ReadAsJsonAsync() ? await response.Content.ReadAsJsonAsync()
: (JsonElement?) null; : null;
} }
public async IAsyncEnumerable<Guild> GetUserGuildsAsync() public async IAsyncEnumerable<Guild> GetUserGuildsAsync()
@ -118,29 +119,30 @@ namespace DiscordChatExporter.Core.Discord
{ {
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels"); var response = await GetJsonResponseAsync($"guilds/{guildId}/channels");
var orderedResponse = response var responseOrdered = response
.EnumerateArray() .EnumerateArray()
.OrderBy(j => j.GetProperty("position").GetInt32()) .OrderBy(j => j.GetProperty("position").GetInt32())
.ThenBy(j => ulong.Parse(j.GetProperty("id").GetString())) .ThenBy(j => Snowflake.Parse(j.GetProperty("id").GetString()))
.ToArray(); .ToArray();
var categories = orderedResponse var categories = responseOrdered
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelType.GuildCategory) .Where(j => j.GetProperty("type").GetInt32() == (int) ChannelType.GuildCategory)
.Select((j, index) => ChannelCategory.Parse(j, index + 1)) .Select((j, index) => ChannelCategory.Parse(j, index + 1))
.ToDictionary(j => j.Id.ToString()); .ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
var position = 0; var position = 0;
foreach (var channelJson in orderedResponse) foreach (var channelJson in responseOrdered)
{ {
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString(); var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString();
var category = !string.IsNullOrWhiteSpace(parentId) var category = !string.IsNullOrWhiteSpace(parentId)
? categories.GetValueOrDefault(parentId) ? categories.GetValueOrDefault(parentId)
: null; : null;
var channel = Channel.Parse(channelJson, category, position); var channel = Channel.Parse(channelJson, category, position);
// Skip non-text channels // We are only interested in text channels
if (!channel.IsTextChannel) if (!channel.IsTextChannel)
continue; continue;
@ -178,15 +180,12 @@ namespace DiscordChatExporter.Core.Discord
var response = await GetJsonResponseAsync($"channels/{channelId}"); var response = await GetJsonResponseAsync($"channels/{channelId}");
return ChannelCategory.Parse(response); return ChannelCategory.Parse(response);
} }
/*** // In some cases, the Discord API returns an empty body when requesting channel category.
* In some cases, the Discord API returns an empty body when requesting some channel category info. // Instead, we use an empty channel category as a fallback.
* Instead, we use an empty channel category as a fallback.
*/
catch (DiscordChatExporterException) catch (DiscordChatExporterException)
{ {
return ChannelCategory.Empty; return ChannelCategory.Empty;
} }
} }
public async ValueTask<Channel> GetChannelAsync(Snowflake channelId) public async ValueTask<Channel> GetChannelAsync(Snowflake channelId)
@ -221,7 +220,7 @@ namespace DiscordChatExporter.Core.Discord
IProgress<double>? progress = 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 // This snapshots the boundaries, which means that messages posted after the export started
// will not appear in the output. // will not appear in the output.
// Additionally, it provides the date of the last message, which is used to calculate progress. // Additionally, it provides the date of the last message, which is used to calculate progress.
var lastMessage = await TryGetLastMessageAsync(channelId, before); var lastMessage = await TryGetLastMessageAsync(channelId, before);
@ -271,7 +270,7 @@ namespace DiscordChatExporter.Core.Discord
progress.Report(exportedDuration / totalDuration); progress.Report(exportedDuration / totalDuration);
} }
// Avoid division by zero if all messages have the exact same timestamp // Avoid division by zero if all messages have the exact same timestamp
// (which can happen easily if there's only one message in the channel) // (which may be the case if there's only one message in the channel)
else else
{ {
progress.Report(1); progress.Report(1);
@ -284,4 +283,4 @@ namespace DiscordChatExporter.Core.Discord
} }
} }
} }
} }

@ -10,8 +10,9 @@ namespace DiscordChatExporter.Core.Discord
public Snowflake(ulong value) => Value = value; public Snowflake(ulong value) => Value = value;
public DateTimeOffset ToDate() => public DateTimeOffset ToDate() => DateTimeOffset.FromUnixTimeMilliseconds(
DateTimeOffset.FromUnixTimeMilliseconds((long) ((Value >> 22) + 1420070400000UL)).ToLocalTime(); (long) ((Value >> 22) + 1420070400000UL)
).ToLocalTime();
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
} }
@ -53,9 +54,11 @@ namespace DiscordChatExporter.Core.Discord
public static Snowflake Parse(string str) => Parse(str, null); public static Snowflake Parse(string str) => Parse(str, null);
} }
public partial struct Snowflake : IEquatable<Snowflake> public partial struct Snowflake : IComparable<Snowflake>, IEquatable<Snowflake>
{ {
public bool Equals(Snowflake other) => Value == other.Value; public int CompareTo(Snowflake other) => Value.CompareTo(other.Value);
public bool Equals(Snowflake other) => CompareTo(other) == 0;
public override bool Equals(object? obj) => obj is Snowflake other && Equals(other); public override bool Equals(object? obj) => obj is Snowflake other && Equals(other);

@ -79,7 +79,7 @@ namespace DiscordChatExporter.Core.Exporting
: filePath; : filePath;
// HACK: for HTML, we need to format the URL properly // HACK: for HTML, we need to format the URL properly
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight) if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
{ {
// Need to escape each path segment while keeping the directory separators intact // Need to escape each path segment while keeping the directory separators intact
return relativeFilePath return relativeFilePath
@ -93,7 +93,7 @@ namespace DiscordChatExporter.Core.Exporting
// Try to catch only exceptions related to failed HTTP requests // Try to catch only exceptions related to failed HTTP requests
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332 // https://github.com/Tyrrrz/DiscordChatExporter/issues/332
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372 // https://github.com/Tyrrrz/DiscordChatExporter/issues/372
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException) catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
{ {
// TODO: add logging so we can be more liberal with catching exceptions // TODO: add logging so we can be more liberal with catching exceptions
// We don't want this to crash the exporting process in case of failure // We don't want this to crash the exporting process in case of failure

@ -36,9 +36,13 @@ namespace DiscordChatExporter.Core.Exporting.Writers.Html
internal partial class MessageGroup internal partial class MessageGroup
{ {
public static bool CanJoin(Message message1, Message message2) => public static bool CanJoin(Message message1, Message message2) =>
// Must be from the same author
message1.Author.Id == message2.Author.Id && message1.Author.Id == message2.Author.Id &&
// Author's name must not have changed between messages
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) && string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
// Duration between messages must be 7 minutes or less
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 && (message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
// Other message must not be a reply
message2.Reference is null; message2.Reference is null;
public static MessageGroup Join(IReadOnlyList<Message> messages) public static MessageGroup Join(IReadOnlyList<Message> messages)

@ -20,6 +20,8 @@ namespace DiscordChatExporter.Core.Exporting.Writers
{ {
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = true, Indented = true,
// Validation errors may mask actual failures
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
SkipValidation = true SkipValidation = true
}); });
} }
@ -300,4 +302,4 @@ namespace DiscordChatExporter.Core.Exporting.Writers
await base.DisposeAsync(); await base.DisposeAsync();
} }
} }
} }

@ -9,6 +9,6 @@ namespace DiscordChatExporter.Core.Utils.Extensions
public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct => public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct =>
!predicate(value) !predicate(value)
? value ? value
: (T?) null; : null;
} }
} }

@ -4,9 +4,14 @@ namespace DiscordChatExporter.Core.Utils.Extensions
{ {
public static class StringExtensions public static class StringExtensions
{ {
public static string? NullIfWhiteSpace(this string str) =>
!string.IsNullOrWhiteSpace(str)
? str
: null;
public static string Truncate(this string str, int charCount) => public static string Truncate(this string str, int charCount) =>
str.Length > charCount str.Length > charCount
? str.Substring(0, charCount) ? str[..charCount]
: str; : str;
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>

@ -5,6 +5,7 @@ using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Utils.Extensions;
using Polly; using Polly;
namespace DiscordChatExporter.Core.Utils namespace DiscordChatExporter.Core.Utils
@ -41,21 +42,24 @@ namespace DiscordChatExporter.Core.Utils
}, },
(_, _, _, _) => Task.CompletedTask); (_, _, _, _) => Task.CompletedTask);
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex) private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex) =>
{
// This is extremely frail, but there's no other way // This is extremely frail, but there's no other way
var statusCodeRaw = Regex.Match(ex.Message, @": (\d+) \(").Groups[1].Value; Regex
return !string.IsNullOrWhiteSpace(statusCodeRaw) .Match(ex.Message, @": (\d+) \(")
? (HttpStatusCode) int.Parse(statusCodeRaw, CultureInfo.InvariantCulture) .Groups[1]
: (HttpStatusCode?) null; .Value
} .NullIfWhiteSpace()?
.Pipe(s => (HttpStatusCode) int.Parse(s, CultureInfo.InvariantCulture));
public static IAsyncPolicy ExceptionPolicy { get; } = public static IAsyncPolicy ExceptionPolicy { get; } =
Policy Policy
.Handle<IOException>() // dangerous .Handle<IOException>() // dangerous
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.TooManyRequests) .Or<HttpRequestException>(ex =>
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.RequestTimeout) TryGetStatusCodeFromException(ex) is
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) >= HttpStatusCode.InternalServerError) HttpStatusCode.TooManyRequests or
HttpStatusCode.RequestTimeout or
HttpStatusCode.InternalServerError
)
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1)); .WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
} }
} }
Loading…
Cancel
Save