You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DiscordChatExporter/DiscordChatExporter.Core/Discord/DiscordClient.cs

288 lines
11 KiB

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
4 years ago
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Http;
using JsonExtensions.Reading;
using Tyrrrz.Extensions;
4 years ago
namespace DiscordChatExporter.Core.Discord
{
public class DiscordClient
{
4 years ago
private readonly HttpClient _httpClient;
private readonly AuthToken _token;
7 years ago
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
4 years ago
4 years ago
public DiscordClient(HttpClient httpClient, AuthToken token)
{
4 years ago
_httpClient = httpClient;
_token = token;
}
4 years ago
public DiscordClient(AuthToken token)
: this(Http.Client, token) {}
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) =>
await Http.ResponsePolicy.ExecuteAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
4 years ago
request.Headers.Authorization = _token.GetAuthenticationHeader();
4 years ago
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
});
5 years ago
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
{
using var response = await GetResponseAsync(url);
if (!response.IsSuccessStatusCode)
{
throw response.StatusCode switch
{
HttpStatusCode.Unauthorized => DiscordChatExporterException.Unauthorized(),
HttpStatusCode.Forbidden => DiscordChatExporterException.Forbidden(),
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(),
_ => DiscordChatExporterException.FailedHttpRequest(response)
};
}
return await response.Content.ReadAsJsonAsync();
}
private async ValueTask<JsonElement?> TryGetJsonResponseAsync(string url)
{
using var response = await GetResponseAsync(url);
return response.IsSuccessStatusCode
? await response.Content.ReadAsJsonAsync()
: (JsonElement?) null;
}
public async IAsyncEnumerable<Guild> GetUserGuildsAsync()
{
4 years ago
yield return Guild.DirectMessages;
var currentAfter = Snowflake.Zero;
while (true)
{
4 years ago
var url = new UrlBuilder()
.SetPath("users/@me/guilds")
.SetQueryParameter("limit", "100")
.SetQueryParameter("after", currentAfter.ToString())
4 years ago
.Build();
var response = await GetJsonResponseAsync(url);
7 years ago
var isEmpty = true;
foreach (var guild in response.EnumerateArray().Select(Guild.Parse))
{
4 years ago
yield return guild;
currentAfter = guild.Id;
isEmpty = false;
}
if (isEmpty)
yield break;
}
}
public async ValueTask<Guild> GetGuildAsync(Snowflake guildId)
4 years ago
{
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
var response = await GetJsonResponseAsync($"guilds/{guildId}");
4 years ago
return Guild.Parse(response);
}
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(Snowflake guildId)
4 years ago
{
if (guildId == Guild.DirectMessages.Id)
{
var response = await GetJsonResponseAsync("users/@me/channels");
4 years ago
foreach (var channelJson in response.EnumerateArray())
yield return Channel.Parse(channelJson);
}
else
{
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels");
4 years ago
var orderedResponse = response
4 years ago
.EnumerateArray()
.OrderBy(j => j.GetProperty("position").GetInt32())
4 years ago
.ThenBy(j => ulong.Parse(j.GetProperty("id").GetString()))
.ToArray();
4 years ago
var categories = orderedResponse
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelType.GuildCategory)
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
.ToDictionary(j => j.Id.ToString());
var position = 0;
foreach (var channelJson in orderedResponse)
4 years ago
{
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString();
var category = !string.IsNullOrWhiteSpace(parentId)
? categories.GetValueOrDefault(parentId)
: null;
4 years ago
var channel = Channel.Parse(channelJson, category, position);
4 years ago
// Skip non-text channels
if (!channel.IsTextChannel)
continue;
position++;
4 years ago
yield return channel;
}
}
}
public async IAsyncEnumerable<Role> GetGuildRolesAsync(Snowflake guildId)
{
4 years ago
if (guildId == Guild.DirectMessages.Id)
yield break;
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles");
4 years ago
foreach (var roleJson in response.EnumerateArray())
yield return Role.Parse(roleJson);
}
public async ValueTask<Member?> TryGetGuildMemberAsync(Snowflake guildId, User user)
{
if (guildId == Guild.DirectMessages.Id)
4 years ago
return Member.CreateForUser(user);
var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{user.Id}");
4 years ago
return response?.Pipe(Member.Parse);
}
public async ValueTask<ChannelCategory> GetChannelCategoryAsync(Snowflake channelId)
4 years ago
{
try
{
var response = await GetJsonResponseAsync($"channels/{channelId}");
return ChannelCategory.Parse(response);
}
/***
* 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.
*/
catch (DiscordChatExporterException)
{
return ChannelCategory.Empty;
}
4 years ago
}
public async ValueTask<Channel> GetChannelAsync(Snowflake channelId)
4 years ago
{
var response = await GetJsonResponseAsync($"channels/{channelId}");
var parentId = response.GetPropertyOrNull("parent_id")?.GetString().Pipe(Snowflake.Parse);
var category = parentId is not null
? await GetChannelCategoryAsync(parentId.Value)
4 years ago
: null;
4 years ago
return Channel.Parse(response, category);
}
private async ValueTask<Message?> TryGetLastMessageAsync(Snowflake channelId, Snowflake? before = null)
{
4 years ago
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "1")
.SetQueryParameter("before", before?.ToString())
4 years ago
.Build();
var response = await GetJsonResponseAsync(url);
4 years ago
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
}
4 years ago
public async IAsyncEnumerable<Message> GetMessagesAsync(
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
4 years ago
IProgress<double>? progress = null)
{
// 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.
4 years ago
var lastMessage = await TryGetLastMessageAsync(channelId, before);
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
yield break;
// Keep track of first message in range in order to calculate progress
var firstMessage = default(Message);
var currentAfter = after ?? Snowflake.Zero;
4 years ago
while (true)
{
4 years ago
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "100")
.SetQueryParameter("after", currentAfter.ToString())
4 years ago
.Build();
var response = await GetJsonResponseAsync(url);
var messages = response
.EnumerateArray()
4 years ago
.Select(Message.Parse)
.Reverse() // reverse because messages appear newest first
.ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
4 years ago
yield break;
4 years ago
foreach (var message in messages)
{
firstMessage ??= message;
4 years ago
// Ensure messages are in range (take into account that last message could have been deleted)
if (message.Timestamp > lastMessage.Timestamp)
yield break;
// Report progress based on the duration of exported messages divided by total
if (progress is not null)
{
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
if (totalDuration > TimeSpan.Zero)
{
progress.Report(exportedDuration / totalDuration);
}
// 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)
else
{
progress.Report(1);
}
}
yield return message;
currentAfter = message.Id;
4 years ago
}
}
}
}
}