mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-04-27 08:23:03 +00:00
Self-contained export (#321)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user