DiscordChatExporter/DiscordChatExporter.Core/Discord/DiscordClient.cs

702 lines
26 KiB
C#
Raw Permalink Normal View History

using System;
using System.Collections.Generic;
2022-12-15 12:55:50 -05:00
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
2021-02-21 20:15:09 -05:00
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
2022-02-16 07:30:26 -05:00
using Gress;
2020-11-28 17:17:58 -05:00
using JsonExtensions.Http;
using JsonExtensions.Reading;
2021-12-08 16:50:21 -05:00
namespace DiscordChatExporter.Core.Discord;
2023-12-10 15:32:45 -05:00
public class DiscordClient(string token)
{
2023-02-12 07:52:41 -05:00
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
private TokenKind? _resolvedTokenKind;
2022-01-03 17:52:16 -05:00
private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url,
TokenKind tokenKind,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
2022-01-03 17:52:16 -05:00
{
2023-09-28 12:30:12 -04:00
return await Http.ResponseResiliencePipeline.ExecuteAsync(
2023-08-22 14:17:19 -04:00
async innerCancellationToken =>
2022-12-15 13:40:28 -05:00
{
2023-08-22 14:17:19 -04:00
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
// Don't validate because the token can have special characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
2023-12-28 17:08:16 -05:00
request.Headers.TryAddWithoutValidation(
"Authorization",
tokenKind == TokenKind.Bot ? $"Bot {token}" : token
);
2023-08-22 14:17:19 -04:00
var response = await Http.Client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
// If this was the last request available before hitting the rate limit,
// wait out the reset time so that future requests can succeed.
// This may add an unnecessary delay in case the user doesn't intend to
// make any more requests, but implementing a smarter solution would
// require properly keeping track of Discord's global/per-route/per-resource
// rate limits and that's just way too much effort.
// https://discord.com/developers/docs/topics/rate-limits
2023-11-09 06:06:00 -05:00
var remainingRequestCount = response
2023-12-28 17:08:16 -05:00
.Headers.TryGetValue("X-RateLimit-Remaining")
2023-08-22 14:17:19 -04:00
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
2023-11-09 06:06:00 -05:00
var resetAfterDelay = response
2023-12-28 17:08:16 -05:00
.Headers.TryGetValue("X-RateLimit-Reset-After")
2023-08-22 14:17:19 -04:00
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
.Pipe(TimeSpan.FromSeconds);
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{
var delay =
2024-04-26 21:17:46 -04:00
// Adding a small buffer to the reset time reduces the chance of getting
// rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which
// is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
2022-12-15 13:40:28 -05:00
2023-08-22 14:17:19 -04:00
await Task.Delay(delay, innerCancellationToken);
}
2022-12-15 12:55:50 -05:00
2023-08-22 14:17:19 -04:00
return response;
},
cancellationToken
);
}
2022-01-03 17:52:16 -05:00
2023-09-30 10:32:13 -04:00
private async ValueTask<TokenKind> ResolveTokenKindAsync(
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
{
2023-09-30 10:32:13 -04:00
if (_resolvedTokenKind is not null)
return _resolvedTokenKind.Value;
// Try authenticating as a user
using var userResponse = await GetResponseAsync(
"users/@me",
TokenKind.User,
cancellationToken
);
2022-01-03 17:52:16 -05:00
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
2023-09-30 10:32:13 -04:00
return (_resolvedTokenKind = TokenKind.User).Value;
// Try authenticating as a bot
using var botResponse = await GetResponseAsync(
"users/@me",
TokenKind.Bot,
2022-01-03 17:52:16 -05:00
cancellationToken
);
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
2023-09-30 10:32:13 -04:00
return (_resolvedTokenKind = TokenKind.Bot).Value;
throw new DiscordChatExporterException("Authentication token is invalid.", true);
2022-01-03 17:52:16 -05:00
}
2021-12-08 16:50:21 -05:00
private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
2023-09-30 10:32:13 -04:00
) =>
await GetResponseAsync(
url,
await ResolveTokenKindAsync(cancellationToken),
cancellationToken
);
2020-04-22 17:32:48 -04:00
2021-12-08 16:50:21 -05:00
private async ValueTask<JsonElement> GetJsonResponseAsync(
string url,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
using var response = await GetResponseAsync(url, cancellationToken);
2020-10-24 14:15:58 -04:00
2021-12-08 16:50:21 -05:00
if (!response.IsSuccessStatusCode)
{
2021-12-08 16:50:21 -05:00
throw response.StatusCode switch
2020-10-24 14:15:58 -04:00
{
2023-08-22 14:17:19 -04:00
HttpStatusCode.Unauthorized
=> throw new DiscordChatExporterException(
"Authentication token is invalid.",
true
),
HttpStatusCode.Forbidden
=> throw new DiscordChatExporterException(
$"Request to '{url}' failed: forbidden."
),
HttpStatusCode.NotFound
=> throw new DiscordChatExporterException(
$"Request to '{url}' failed: not found."
),
_
=> throw new DiscordChatExporterException(
$"""
2024-04-26 21:17:46 -04:00
Request to '{url}' failed: {response
.StatusCode.ToString()
.ToSpaceSeparatedWords()
.ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(
cancellationToken
)}
2023-09-30 10:32:13 -04:00
""",
2023-08-22 14:17:19 -04:00
true
)
2021-12-08 16:50:21 -05:00
};
}
2019-09-26 13:44:28 -04:00
2021-12-08 16:50:21 -05:00
return await response.Content.ReadAsJsonAsync(cancellationToken);
}
2020-04-21 14:30:42 -04:00
2021-12-08 16:50:21 -05:00
private async ValueTask<JsonElement?> TryGetJsonResponseAsync(
string url,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
using var response = await GetResponseAsync(url, cancellationToken);
return response.IsSuccessStatusCode
? await response.Content.ReadAsJsonAsync(cancellationToken)
: null;
}
2023-09-30 10:32:13 -04:00
public async ValueTask<Application> GetApplicationAsync(
CancellationToken cancellationToken = default
)
{
var response = await GetJsonResponseAsync("applications/@me", cancellationToken);
return Application.Parse(response);
}
public async ValueTask<User?> TryGetUserAsync(
Snowflake userId,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
{
var response = await TryGetJsonResponseAsync($"users/{userId}", cancellationToken);
return response?.Pipe(User.Parse);
}
2021-12-08 16:50:21 -05:00
public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
2023-08-22 14:17:19 -04:00
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
yield return Guild.DirectMessages;
2020-04-27 06:24:20 -04:00
2021-12-08 16:50:21 -05:00
var currentAfter = Snowflake.Zero;
while (true)
{
2021-12-08 16:50:21 -05:00
var url = new UrlBuilder()
.SetPath("users/@me/guilds")
.SetQueryParameter("limit", "100")
.SetQueryParameter("after", currentAfter.ToString())
.Build();
2021-12-08 16:50:21 -05:00
var response = await GetJsonResponseAsync(url, cancellationToken);
2023-07-31 13:22:10 -04:00
var count = 0;
2023-05-18 19:13:19 -04:00
foreach (var guildJson in response.EnumerateArray())
{
2023-05-18 19:13:19 -04:00
var guild = Guild.Parse(guildJson);
2021-12-08 16:50:21 -05:00
yield return guild;
2021-12-08 16:50:21 -05:00
currentAfter = guild.Id;
2023-07-31 13:22:10 -04:00
count++;
2021-12-08 16:50:21 -05:00
}
2017-10-29 07:09:57 -04:00
2023-07-31 13:22:10 -04:00
if (count <= 0)
2021-12-08 16:50:21 -05:00
yield break;
}
}
2021-12-08 16:50:21 -05:00
public async ValueTask<Guild> GetGuildAsync(
Snowflake guildId,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
2021-12-08 16:50:21 -05:00
var response = await GetJsonResponseAsync($"guilds/{guildId}", cancellationToken);
return Guild.Parse(response);
}
2021-12-08 16:50:21 -05:00
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(
Snowflake guildId,
2023-08-22 14:17:19 -04:00
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
if (guildId == Guild.DirectMessages.Id)
2020-04-24 07:18:41 -04:00
{
2021-12-08 16:50:21 -05:00
var response = await GetJsonResponseAsync("users/@me/channels", cancellationToken);
foreach (var channelJson in response.EnumerateArray())
yield return Channel.Parse(channelJson);
2020-04-24 07:18:41 -04:00
}
2021-12-08 16:50:21 -05:00
else
2020-04-24 07:18:41 -04:00
{
2023-08-22 14:17:19 -04:00
var response = await GetJsonResponseAsync(
$"guilds/{guildId}/channels",
cancellationToken
);
2020-04-24 07:18:41 -04:00
2023-05-18 19:13:19 -04:00
var channelsJson = response
2021-12-08 16:50:21 -05:00
.EnumerateArray()
2023-07-16 16:05:53 -04:00
.OrderBy(j => j.GetProperty("position").GetInt32())
2021-12-08 16:50:21 -05:00
.ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse))
.ToArray();
2020-04-24 07:18:41 -04:00
2023-07-16 15:55:36 -04:00
var parentsById = channelsJson
2023-05-24 14:51:24 -04:00
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelKind.GuildCategory)
2023-07-16 15:55:36 -04:00
.Select((j, i) => Channel.Parse(j, null, i + 1))
.ToDictionary(j => j.Id);
2021-01-28 15:01:13 -05:00
2023-05-18 19:13:19 -04:00
// Discord channel positions are relative, so we need to normalize them
// so that the user may refer to them more easily in file name templates.
2021-12-08 16:50:21 -05:00
var position = 0;
2021-01-28 15:01:13 -05:00
2023-05-18 19:13:19 -04:00
foreach (var channelJson in channelsJson)
2021-12-08 16:50:21 -05:00
{
2023-07-16 15:55:36 -04:00
var parent = channelJson
2023-08-22 14:17:19 -04:00
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse)
2023-07-16 15:55:36 -04:00
.Pipe(parentsById.GetValueOrDefault);
2021-06-16 09:52:18 -04:00
2023-07-16 15:55:36 -04:00
yield return Channel.Parse(channelJson, parent, position);
2021-12-08 16:50:21 -05:00
position++;
2020-04-24 07:18:41 -04:00
}
}
2021-12-08 16:50:21 -05:00
}
2020-04-24 07:18:41 -04:00
2023-07-16 15:55:36 -04:00
public async IAsyncEnumerable<Channel> GetGuildThreadsAsync(
2023-05-24 14:51:24 -04:00
Snowflake guildId,
bool includeArchived = false,
2023-09-03 14:18:49 -04:00
Snowflake? before = null,
Snowflake? after = null,
2023-08-22 14:17:19 -04:00
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
2023-05-24 14:51:24 -04:00
{
if (guildId == Guild.DirectMessages.Id)
yield break;
2023-09-03 14:43:55 -04:00
var channels = (await GetGuildChannelsAsync(guildId, cancellationToken))
2023-09-03 14:18:49 -04:00
// Categories cannot have threads
.Where(c => !c.IsCategory)
2023-09-03 14:18:49 -04:00
// Voice channels cannot have threads
.Where(c => !c.IsVoice)
2023-09-03 14:43:55 -04:00
// Empty channels cannot have threads
.Where(c => !c.IsEmpty)
// If the 'before' boundary is specified, skip channels that don't have messages
// for that range, because thread-start event should always be accompanied by a message.
// Note that we don't perform a similar check for the 'after' boundary, because
// threads may have messages in range, even if the parent channel doesn't.
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
.ToArray();
2023-09-03 14:18:49 -04:00
2023-05-24 14:51:24 -04:00
// User accounts can only fetch threads using the search endpoint
2023-10-04 08:24:48 -04:00
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
2023-05-24 14:51:24 -04:00
{
// Active threads
2023-09-03 14:43:55 -04:00
foreach (var channel in channels)
2023-05-24 14:51:24 -04:00
{
var currentOffset = 0;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/search")
2023-09-03 14:18:49 -04:00
.SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", "false")
2023-05-24 14:51:24 -04:00
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access or channels without threads
2023-05-24 14:51:24 -04:00
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
2023-09-03 14:43:55 -04:00
var breakOuter = false;
2023-09-03 14:18:49 -04:00
2023-08-22 14:17:19 -04:00
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
2023-05-24 14:51:24 -04:00
{
2023-09-03 14:18:49 -04:00
var thread = Channel.Parse(threadJson, channel);
2023-09-03 14:43:55 -04:00
// If the 'after' boundary is specified, we can break early,
// because threads are sorted by last message time.
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
2023-09-03 14:18:49 -04:00
{
2023-09-03 14:43:55 -04:00
breakOuter = true;
2023-09-03 14:18:49 -04:00
break;
}
yield return thread;
2023-05-24 14:51:24 -04:00
currentOffset++;
}
2023-09-03 14:43:55 -04:00
if (breakOuter)
2023-09-03 14:18:49 -04:00
break;
2023-05-24 14:51:24 -04:00
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
// Archived threads
if (includeArchived)
{
2023-09-03 14:43:55 -04:00
foreach (var channel in channels)
{
var currentOffset = 0;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/search")
2023-09-03 14:18:49 -04:00
.SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", "true")
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access or channels without threads
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
2023-09-03 14:43:55 -04:00
var breakOuter = false;
2023-09-03 14:18:49 -04:00
2023-08-22 14:17:19 -04:00
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
{
2023-09-03 14:18:49 -04:00
var thread = Channel.Parse(threadJson, channel);
2023-09-03 14:43:55 -04:00
// If the 'after' boundary is specified, we can break early,
// because threads are sorted by last message time.
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
2023-09-03 14:18:49 -04:00
{
2023-09-03 14:43:55 -04:00
breakOuter = true;
2023-09-03 14:18:49 -04:00
break;
}
yield return thread;
currentOffset++;
}
2023-09-03 14:43:55 -04:00
if (breakOuter)
2023-09-03 14:18:49 -04:00
break;
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
2023-05-24 14:51:24 -04:00
}
// Bot accounts can only fetch threads using the threads endpoint
else
{
// Active threads
{
2023-09-03 14:43:55 -04:00
var parentsById = channels.ToDictionary(c => c.Id);
2023-07-16 15:55:36 -04:00
2023-08-22 14:17:19 -04:00
var response = await GetJsonResponseAsync(
$"guilds/{guildId}/threads/active",
cancellationToken
);
2023-05-24 14:51:24 -04:00
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
{
2023-07-16 15:55:36 -04:00
var parent = threadJson
2023-08-22 14:17:19 -04:00
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse)
2023-07-16 15:55:36 -04:00
.Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(threadJson, parent);
}
2023-05-24 14:51:24 -04:00
}
// Archived threads
if (includeArchived)
2023-05-24 14:51:24 -04:00
{
2023-09-03 14:43:55 -04:00
foreach (var channel in channels)
2023-05-24 14:51:24 -04:00
{
// Public archived threads
{
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(
$"channels/{channel.Id}/threads/archived/public",
cancellationToken
);
2023-05-24 14:51:24 -04:00
if (response is null)
continue;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
yield return Channel.Parse(threadJson, channel);
}
2023-05-24 14:51:24 -04:00
// Private archived threads
{
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(
$"channels/{channel.Id}/threads/archived/private",
cancellationToken
);
2023-05-24 14:51:24 -04:00
if (response is null)
continue;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
yield return Channel.Parse(threadJson, channel);
}
2023-05-24 14:51:24 -04:00
}
}
}
}
2021-12-08 16:50:21 -05:00
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
Snowflake guildId,
2023-08-22 14:17:19 -04:00
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
if (guildId == Guild.DirectMessages.Id)
yield break;
2020-04-24 07:18:41 -04:00
2021-12-08 16:50:21 -05:00
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
foreach (var roleJson in response.EnumerateArray())
yield return Role.Parse(roleJson);
}
public async ValueTask<Member?> TryGetGuildMemberAsync(
2021-12-08 16:50:21 -05:00
Snowflake guildId,
Snowflake memberId,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
if (guildId == Guild.DirectMessages.Id)
return null;
2020-04-24 07:18:41 -04:00
2023-08-22 14:17:19 -04:00
var response = await TryGetJsonResponseAsync(
$"guilds/{guildId}/members/{memberId}",
cancellationToken
);
2023-02-22 17:54:02 -05:00
return response?.Pipe(j => Member.Parse(j, guildId));
2021-12-08 16:50:21 -05:00
}
2020-04-24 07:18:41 -04:00
2023-07-16 16:05:53 -04:00
public async ValueTask<Invite?> TryGetInviteAsync(
string code,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
{
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
return response?.Pipe(Invite.Parse);
}
2023-07-16 16:05:53 -04:00
public async ValueTask<Channel> GetChannelAsync(
2021-12-08 16:50:21 -05:00
Snowflake channelId,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
2023-07-16 16:05:53 -04:00
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
2023-05-18 19:13:19 -04:00
var parentId = response
2023-08-22 14:17:19 -04:00
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
2018-05-06 06:09:25 -04:00
try
{
var parent = parentId is not null
? await GetChannelAsync(parentId.Value, cancellationToken)
: null;
2021-12-08 16:50:21 -05:00
return Channel.Parse(response, parent);
}
// It's possible for the parent channel to be inaccessible, despite the
// child channel being accessible.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
catch (DiscordChatExporterException)
{
return Channel.Parse(response);
}
2021-12-08 16:50:21 -05:00
}
2018-05-06 06:09:25 -04:00
2021-12-08 16:50:21 -05:00
private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId,
Snowflake? before = null,
2023-08-22 14:17:19 -04:00
CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "1")
.SetQueryParameter("before", before?.ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
}
public async IAsyncEnumerable<Message> GetMessagesAsync(
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
2022-02-16 07:30:26 -05:00
IProgress<Percentage>? progress = null,
2023-08-22 14:17:19 -04:00
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
2021-12-08 16:50:21 -05:00
{
2023-05-18 19:13:19 -04:00
// Get the last message in the specified range, so we can later calculate the
// progress based on the difference between message timestamps.
// This also snapshots the boundaries, which means that messages posted after
// the export started will not appear in the output.
2021-12-08 16:50:21 -05:00
var lastMessage = await TryGetLastMessageAsync(channelId, before, cancellationToken);
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
yield break;
2023-05-20 00:09:19 -04:00
// Keep track of the first message in range in order to calculate the progress
2021-12-08 16:50:21 -05:00
var firstMessage = default(Message);
2023-05-20 00:09:19 -04:00
2021-12-08 16:50:21 -05:00
var currentAfter = after ?? Snowflake.Zero;
while (true)
{
2020-04-22 17:32:48 -04:00
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
2021-12-08 16:50:21 -05:00
.SetQueryParameter("limit", "100")
.SetQueryParameter("after", currentAfter.ToString())
2020-04-22 17:32:48 -04:00
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
2021-12-08 16:50:21 -05:00
var messages = response
.EnumerateArray()
.Select(Message.Parse)
2023-05-18 19:13:19 -04:00
// Messages are returned from newest to oldest, so we need to reverse them
.Reverse()
2021-12-08 16:50:21 -05:00
.ToArray();
2021-12-08 16:50:21 -05:00
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
yield break;
2020-04-22 17:32:48 -04:00
2023-10-04 08:24:48 -04:00
// If all messages are empty, make sure that it's not because the bot account doesn't
// have the Message Content Intent enabled.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
if (
messages.All(m => m.IsEmpty)
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
)
{
var application = await GetApplicationAsync(cancellationToken);
if (!application.IsMessageContentIntentEnabled)
{
throw new DiscordChatExporterException(
2023-11-17 11:37:52 -05:00
"Provided bot account does not have the Message Content Intent enabled.",
2023-10-04 08:24:48 -04:00
true
);
}
}
2021-12-08 16:50:21 -05:00
foreach (var message in messages)
{
2021-12-08 16:50:21 -05:00
firstMessage ??= message;
2023-05-18 19:13:19 -04:00
// Ensure that the messages are in range
2021-12-08 16:50:21 -05:00
if (message.Timestamp > lastMessage.Timestamp)
2020-04-24 07:18:41 -04:00
yield break;
2023-05-18 19:13:19 -04:00
// Report progress based on timestamps
2021-12-08 16:50:21 -05:00
if (progress is not null)
{
2021-12-08 16:50:21 -05:00
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
2020-04-22 17:32:48 -04:00
2023-08-22 14:17:19 -04:00
progress.Report(
Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp
// (which happens when there's only one message in the channel)
totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration
: 1
)
);
2020-04-22 17:32:48 -04:00
}
2021-12-08 16:50:21 -05:00
yield return message;
currentAfter = message.Id;
}
}
2020-04-21 14:30:42 -04:00
}
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
Snowflake channelId,
Snowflake messageId,
Emoji emoji,
2023-08-22 14:17:19 -04:00
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
2023-07-31 13:22:10 -04:00
var reactionName = emoji.Id is not null
// Custom emoji
? emoji.Name + ':' + emoji.Id
// Standard emoji
: emoji.Name;
var currentAfter = Snowflake.Zero;
while (true)
{
var url = new UrlBuilder()
2023-08-22 14:17:19 -04:00
.SetPath(
$"channels/{channelId}/messages/{messageId}/reactions/{Uri.EscapeDataString(reactionName)}"
)
.SetQueryParameter("limit", "100")
.SetQueryParameter("after", currentAfter.ToString())
.Build();
// Can be null on reactions with an emoji that has been deleted (?)
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1226
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
yield break;
2023-07-31 13:22:10 -04:00
var count = 0;
foreach (var userJson in response.Value.EnumerateArray())
{
2023-07-31 13:22:10 -04:00
var user = User.Parse(userJson);
yield return user;
2023-07-31 13:22:10 -04:00
currentAfter = user.Id;
2023-07-31 13:22:10 -04:00
count++;
}
2023-07-31 13:22:10 -04:00
// Each batch can contain up to 100 users.
// If we got fewer, then it's definitely the last batch.
2023-07-31 13:22:10 -04:00
if (count < 100)
yield break;
}
}
2023-08-22 14:17:19 -04:00
}