feat: upgrade to .NET 6, refactor everything
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Rest.Exceptions;
|
||||
using Myriad.Rest.Ratelimit;
|
||||
@@ -19,305 +15,306 @@ using Polly;
|
||||
using Serilog;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace Myriad.Rest
|
||||
namespace Myriad.Rest;
|
||||
|
||||
public class BaseRestClient: IAsyncDisposable
|
||||
{
|
||||
public class BaseRestClient: IAsyncDisposable
|
||||
private readonly string _baseUrl;
|
||||
private readonly Version _httpVersion = new(2, 0);
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Ratelimiter _ratelimiter;
|
||||
private readonly AsyncPolicy<HttpResponseMessage> _retryPolicy;
|
||||
public EventHandler<(string, int, long)> OnResponseEvent;
|
||||
|
||||
public BaseRestClient(string userAgent, string token, ILogger logger, string baseUrl)
|
||||
{
|
||||
private readonly Version _httpVersion = new(2, 0);
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Ratelimiter _ratelimiter;
|
||||
private readonly AsyncPolicy<HttpResponseMessage> _retryPolicy;
|
||||
private readonly string _baseUrl;
|
||||
_logger = logger.ForContext<BaseRestClient>();
|
||||
_baseUrl = baseUrl;
|
||||
|
||||
public BaseRestClient(string userAgent, string token, ILogger logger, string baseUrl)
|
||||
{
|
||||
_logger = logger.ForContext<BaseRestClient>();
|
||||
_baseUrl = baseUrl;
|
||||
if (!token.StartsWith("Bot "))
|
||||
token = "Bot " + token;
|
||||
|
||||
if (!token.StartsWith("Bot "))
|
||||
token = "Bot " + token;
|
||||
Client = new HttpClient();
|
||||
Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent);
|
||||
Client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", token);
|
||||
|
||||
Client = new HttpClient();
|
||||
Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent);
|
||||
Client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", token);
|
||||
_jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad();
|
||||
|
||||
_jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad();
|
||||
_ratelimiter = new Ratelimiter(logger);
|
||||
var discordPolicy = new DiscordRateLimitPolicy(_ratelimiter);
|
||||
|
||||
_ratelimiter = new Ratelimiter(logger);
|
||||
var discordPolicy = new DiscordRateLimitPolicy(_ratelimiter);
|
||||
// todo: why doesn't the timeout work? o.o
|
||||
var timeoutPolicy = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(10));
|
||||
|
||||
// todo: why doesn't the timeout work? o.o
|
||||
var timeoutPolicy = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(10));
|
||||
var waitPolicy = Policy
|
||||
.Handle<RatelimitBucketExhaustedException>()
|
||||
.WaitAndRetryAsync(3,
|
||||
(_, e, _) => ((RatelimitBucketExhaustedException)e).RetryAfter,
|
||||
(_, _, _, _) => Task.CompletedTask)
|
||||
.AsAsyncPolicy<HttpResponseMessage>();
|
||||
|
||||
var waitPolicy = Policy
|
||||
.Handle<RatelimitBucketExhaustedException>()
|
||||
.WaitAndRetryAsync(3,
|
||||
(_, e, _) => ((RatelimitBucketExhaustedException)e).RetryAfter,
|
||||
(_, _, _, _) => Task.CompletedTask)
|
||||
.AsAsyncPolicy<HttpResponseMessage>();
|
||||
_retryPolicy = Policy.WrapAsync(timeoutPolicy, waitPolicy, discordPolicy);
|
||||
}
|
||||
|
||||
_retryPolicy = Policy.WrapAsync(timeoutPolicy, waitPolicy, discordPolicy);
|
||||
}
|
||||
public HttpClient Client { get; }
|
||||
|
||||
public HttpClient Client { get; }
|
||||
public EventHandler<(string, int, long)> OnResponseEvent;
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_ratelimiter.Dispose();
|
||||
Client.Dispose();
|
||||
return default;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_ratelimiter.Dispose();
|
||||
Client.Dispose();
|
||||
return default;
|
||||
}
|
||||
|
||||
public async Task<T?> Get<T>(string path, (string endpointName, ulong major) ratelimitParams) where T : class
|
||||
{
|
||||
using var response = await Send(() => new HttpRequestMessage(HttpMethod.Get, _baseUrl + path),
|
||||
ratelimitParams, true);
|
||||
|
||||
// GET-only special case: 404s are nulls and not exceptions
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
public async Task<T?> Post<T>(string path, (string endpointName, ulong major) ratelimitParams, object? body)
|
||||
where T : class
|
||||
{
|
||||
using var response = await Send(() =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
|
||||
SetRequestJsonBody(request, body);
|
||||
return request;
|
||||
}, ratelimitParams);
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
public async Task<T?> PostMultipart<T>(string path, (string endpointName, ulong major) ratelimitParams, object? payload, MultipartFile[]? files)
|
||||
where T : class
|
||||
{
|
||||
using var response = await Send(() =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
|
||||
SetRequestFormDataBody(request, payload, files);
|
||||
return request;
|
||||
}, ratelimitParams);
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
public async Task<T?> Patch<T>(string path, (string endpointName, ulong major) ratelimitParams, object? body)
|
||||
where T : class
|
||||
{
|
||||
using var response = await Send(() =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Patch, _baseUrl + path);
|
||||
SetRequestJsonBody(request, body);
|
||||
return request;
|
||||
}, ratelimitParams);
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
public async Task<T?> Put<T>(string path, (string endpointName, ulong major) ratelimitParams, object? body)
|
||||
where T : class
|
||||
{
|
||||
using var response = await Send(() =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Put, _baseUrl + path);
|
||||
SetRequestJsonBody(request, body);
|
||||
return request;
|
||||
}, ratelimitParams);
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
public async Task Delete(string path, (string endpointName, ulong major) ratelimitParams)
|
||||
{
|
||||
using var _ = await Send(() => new HttpRequestMessage(HttpMethod.Delete, _baseUrl + path), ratelimitParams);
|
||||
}
|
||||
|
||||
private void SetRequestJsonBody(HttpRequestMessage request, object? body)
|
||||
{
|
||||
if (body == null) return;
|
||||
request.Content =
|
||||
new ReadOnlyMemoryContent(JsonSerializer.SerializeToUtf8Bytes(body, _jsonSerializerOptions));
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
}
|
||||
|
||||
private void SetRequestFormDataBody(HttpRequestMessage request, object? payload, MultipartFile[]? files)
|
||||
{
|
||||
var bodyJson = JsonSerializer.SerializeToUtf8Bytes(payload, _jsonSerializerOptions);
|
||||
|
||||
var mfd = new MultipartFormDataContent();
|
||||
mfd.Add(new ByteArrayContent(bodyJson), "payload_json");
|
||||
|
||||
if (files != null)
|
||||
{
|
||||
for (var i = 0; i < files.Length; i++)
|
||||
{
|
||||
var (filename, stream, _) = files[i];
|
||||
mfd.Add(new StreamContent(stream), $"files[{i}]", filename);
|
||||
}
|
||||
}
|
||||
|
||||
request.Content = mfd;
|
||||
}
|
||||
|
||||
private async Task<T?> ReadResponse<T>(HttpResponseMessage response) where T : class
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return null;
|
||||
return await response.Content.ReadFromJsonAsync<T>(_jsonSerializerOptions);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> Send(Func<HttpRequestMessage> createRequest,
|
||||
(string endpointName, ulong major) ratelimitParams,
|
||||
bool ignoreNotFound = false)
|
||||
{
|
||||
return await _retryPolicy.ExecuteAsync(async _ =>
|
||||
{
|
||||
using var __ = LogContext.PushProperty("EndpointName", ratelimitParams.endpointName);
|
||||
|
||||
var request = createRequest();
|
||||
_logger.Debug("Request: {RequestMethod} {RequestPath}",
|
||||
request.Method, CleanForLogging(request.RequestUri!));
|
||||
|
||||
request.Version = _httpVersion;
|
||||
request.VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
|
||||
|
||||
HttpResponseMessage response;
|
||||
|
||||
var stopwatch = new Stopwatch();
|
||||
stopwatch.Start();
|
||||
try
|
||||
{
|
||||
response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
stopwatch.Stop();
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
_logger.Error(exc, "HTTP error: {RequestMethod} {RequestUrl}", request.Method, request.RequestUri);
|
||||
|
||||
// kill the running thread
|
||||
// in PluralKit.Bot, this error is ignored in "IsOurProblem" (PluralKit.Bot/Utils/MiscUtils.cs)
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.Debug(
|
||||
"Response: {RequestMethod} {RequestPath} -> {StatusCode} {ReasonPhrase} (in {ResponseDurationMs} ms)",
|
||||
request.Method, CleanForLogging(request.RequestUri!), (int)response.StatusCode,
|
||||
response.ReasonPhrase, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
await HandleApiError(response, ignoreNotFound);
|
||||
|
||||
OnResponseEvent?.Invoke(null, (
|
||||
GetEndpointMetricsName(response.RequestMessage!),
|
||||
(int)response.StatusCode,
|
||||
stopwatch.ElapsedTicks
|
||||
));
|
||||
|
||||
return response;
|
||||
},
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{DiscordRateLimitPolicy.EndpointContextKey, ratelimitParams.endpointName},
|
||||
{DiscordRateLimitPolicy.MajorContextKey, ratelimitParams.major}
|
||||
});
|
||||
}
|
||||
|
||||
private async ValueTask HandleApiError(HttpResponseMessage response, bool ignoreNotFound)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound && ignoreNotFound)
|
||||
return;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var apiError = TryParseApiError(body);
|
||||
if (apiError != null)
|
||||
_logger.Warning("Discord API error: {DiscordErrorCode} {DiscordErrorMessage}", apiError.Code, apiError.Message);
|
||||
|
||||
throw CreateDiscordException(response, body, apiError);
|
||||
}
|
||||
|
||||
private DiscordRequestException CreateDiscordException(HttpResponseMessage response, string body, DiscordApiError? apiError)
|
||||
{
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => new BadRequestException(response, body, apiError),
|
||||
HttpStatusCode.Forbidden => new ForbiddenException(response, body, apiError),
|
||||
HttpStatusCode.Unauthorized => new UnauthorizedException(response, body, apiError),
|
||||
HttpStatusCode.NotFound => new NotFoundException(response, body, apiError),
|
||||
HttpStatusCode.Conflict => new ConflictException(response, body, apiError),
|
||||
HttpStatusCode.TooManyRequests => new TooManyRequestsException(response, body, apiError),
|
||||
_ => new UnknownDiscordRequestException(response, body, apiError)
|
||||
};
|
||||
}
|
||||
|
||||
private DiscordApiError? TryParseApiError(string responseBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseBody))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<DiscordApiError>(responseBody, _jsonSerializerOptions);
|
||||
}
|
||||
catch (JsonException e)
|
||||
{
|
||||
_logger.Verbose(e, "Error deserializing API error");
|
||||
}
|
||||
public async Task<T?> Get<T>(string path, (string endpointName, ulong major) ratelimitParams) where T : class
|
||||
{
|
||||
using var response = await Send(() => new HttpRequestMessage(HttpMethod.Get, _baseUrl + path),
|
||||
ratelimitParams, true);
|
||||
|
||||
// GET-only special case: 404s are nulls and not exceptions
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
}
|
||||
|
||||
private string NormalizeRoutePath(string url)
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
public async Task<T?> Post<T>(string path, (string endpointName, ulong major) ratelimitParams, object? body)
|
||||
where T : class
|
||||
{
|
||||
using var response = await Send(() =>
|
||||
{
|
||||
url = Regex.Replace(url, @"/channels/\d+", "/channels/{channel_id}");
|
||||
url = Regex.Replace(url, @"/messages/\d+", "/messages/{message_id}");
|
||||
url = Regex.Replace(url, @"/members/\d+", "/members/{user_id}");
|
||||
url = Regex.Replace(url, @"/webhooks/\d+/[^/]+", "/webhooks/{webhook_id}/{webhook_token}");
|
||||
url = Regex.Replace(url, @"/webhooks/\d+", "/webhooks/{webhook_id}");
|
||||
url = Regex.Replace(url, @"/users/\d+", "/users/{user_id}");
|
||||
url = Regex.Replace(url, @"/bans/\d+", "/bans/{user_id}");
|
||||
url = Regex.Replace(url, @"/roles/\d+", "/roles/{role_id}");
|
||||
url = Regex.Replace(url, @"/pins/\d+", "/pins/{message_id}");
|
||||
url = Regex.Replace(url, @"/emojis/\d+", "/emojis/{emoji_id}");
|
||||
url = Regex.Replace(url, @"/guilds/\d+", "/guilds/{guild_id}");
|
||||
url = Regex.Replace(url, @"/integrations/\d+", "/integrations/{integration_id}");
|
||||
url = Regex.Replace(url, @"/permissions/\d+", "/permissions/{overwrite_id}");
|
||||
url = Regex.Replace(url, @"/reactions/[^{/]+/\d+", "/reactions/{emoji}/{user_id}");
|
||||
url = Regex.Replace(url, @"/reactions/[^{/]+", "/reactions/{emoji}");
|
||||
url = Regex.Replace(url, @"/invites/[^{/]+", "/invites/{invite_code}");
|
||||
url = Regex.Replace(url, @"/interactions/\d+/[^{/]+", "/interactions/{interaction_id}/{interaction_token}");
|
||||
url = Regex.Replace(url, @"/interactions/\d+", "/interactions/{interaction_id}");
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
|
||||
SetRequestJsonBody(request, body);
|
||||
return request;
|
||||
}, ratelimitParams);
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
// catch-all for missed IDs
|
||||
url = Regex.Replace(url, @"\d{17,19}", "{snowflake}");
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private string GetEndpointMetricsName(HttpRequestMessage req)
|
||||
public async Task<T?> PostMultipart<T>(string path, (string endpointName, ulong major) ratelimitParams,
|
||||
object? payload, MultipartFile[]? files)
|
||||
where T : class
|
||||
{
|
||||
using var response = await Send(() =>
|
||||
{
|
||||
var localPath = Regex.Replace(req.RequestUri!.LocalPath, @"/api/v\d+", "");
|
||||
var routePath = NormalizeRoutePath(localPath);
|
||||
return $"{req.Method} {routePath}";
|
||||
}
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
|
||||
SetRequestFormDataBody(request, payload, files);
|
||||
return request;
|
||||
}, ratelimitParams);
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
private string CleanForLogging(Uri uri)
|
||||
public async Task<T?> Patch<T>(string path, (string endpointName, ulong major) ratelimitParams, object? body)
|
||||
where T : class
|
||||
{
|
||||
using var response = await Send(() =>
|
||||
{
|
||||
var path = uri.ToString();
|
||||
var request = new HttpRequestMessage(HttpMethod.Patch, _baseUrl + path);
|
||||
SetRequestJsonBody(request, body);
|
||||
return request;
|
||||
}, ratelimitParams);
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
// don't show tokens in logs
|
||||
// todo: anything missing here?
|
||||
path = Regex.Replace(path, @"/webhooks/(\d+)/[^/]+", "/webhooks/$1/:token");
|
||||
path = Regex.Replace(path, @"/interactions/(\d+)/[^{/]+", "/interactions/$1/:token");
|
||||
public async Task<T?> Put<T>(string path, (string endpointName, ulong major) ratelimitParams, object? body)
|
||||
where T : class
|
||||
{
|
||||
using var response = await Send(() =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Put, _baseUrl + path);
|
||||
SetRequestJsonBody(request, body);
|
||||
return request;
|
||||
}, ratelimitParams);
|
||||
return await ReadResponse<T>(response);
|
||||
}
|
||||
|
||||
// remove base URL
|
||||
path = path.Substring(_baseUrl.Length);
|
||||
public async Task Delete(string path, (string endpointName, ulong major) ratelimitParams)
|
||||
{
|
||||
using var _ = await Send(() => new HttpRequestMessage(HttpMethod.Delete, _baseUrl + path), ratelimitParams);
|
||||
}
|
||||
|
||||
return path;
|
||||
private void SetRequestJsonBody(HttpRequestMessage request, object? body)
|
||||
{
|
||||
if (body == null) return;
|
||||
request.Content =
|
||||
new ReadOnlyMemoryContent(JsonSerializer.SerializeToUtf8Bytes(body, _jsonSerializerOptions));
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
}
|
||||
|
||||
private void SetRequestFormDataBody(HttpRequestMessage request, object? payload, MultipartFile[]? files)
|
||||
{
|
||||
var bodyJson = JsonSerializer.SerializeToUtf8Bytes(payload, _jsonSerializerOptions);
|
||||
|
||||
var mfd = new MultipartFormDataContent();
|
||||
mfd.Add(new ByteArrayContent(bodyJson), "payload_json");
|
||||
|
||||
if (files != null)
|
||||
for (var i = 0; i < files.Length; i++)
|
||||
{
|
||||
var (filename, stream, _) = files[i];
|
||||
mfd.Add(new StreamContent(stream), $"files[{i}]", filename);
|
||||
}
|
||||
|
||||
request.Content = mfd;
|
||||
}
|
||||
|
||||
private async Task<T?> ReadResponse<T>(HttpResponseMessage response) where T : class
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return null;
|
||||
return await response.Content.ReadFromJsonAsync<T>(_jsonSerializerOptions);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> Send(Func<HttpRequestMessage> createRequest,
|
||||
(string endpointName, ulong major) ratelimitParams,
|
||||
bool ignoreNotFound = false)
|
||||
{
|
||||
return await _retryPolicy.ExecuteAsync(async _ =>
|
||||
{
|
||||
using var __ = LogContext.PushProperty("EndpointName", ratelimitParams.endpointName);
|
||||
|
||||
var request = createRequest();
|
||||
_logger.Debug("Request: {RequestMethod} {RequestPath}",
|
||||
request.Method, CleanForLogging(request.RequestUri!));
|
||||
|
||||
request.Version = _httpVersion;
|
||||
request.VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
|
||||
|
||||
HttpResponseMessage response;
|
||||
|
||||
var stopwatch = new Stopwatch();
|
||||
stopwatch.Start();
|
||||
try
|
||||
{
|
||||
response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
stopwatch.Stop();
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
_logger.Error(exc, "HTTP error: {RequestMethod} {RequestUrl}", request.Method,
|
||||
request.RequestUri);
|
||||
|
||||
// kill the running thread
|
||||
// in PluralKit.Bot, this error is ignored in "IsOurProblem" (PluralKit.Bot/Utils/MiscUtils.cs)
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.Debug(
|
||||
"Response: {RequestMethod} {RequestPath} -> {StatusCode} {ReasonPhrase} (in {ResponseDurationMs} ms)",
|
||||
request.Method, CleanForLogging(request.RequestUri!), (int)response.StatusCode,
|
||||
response.ReasonPhrase, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
await HandleApiError(response, ignoreNotFound);
|
||||
|
||||
OnResponseEvent?.Invoke(null, (
|
||||
GetEndpointMetricsName(response.RequestMessage!),
|
||||
(int)response.StatusCode,
|
||||
stopwatch.ElapsedTicks
|
||||
));
|
||||
|
||||
return response;
|
||||
},
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{DiscordRateLimitPolicy.EndpointContextKey, ratelimitParams.endpointName},
|
||||
{DiscordRateLimitPolicy.MajorContextKey, ratelimitParams.major}
|
||||
});
|
||||
}
|
||||
|
||||
private async ValueTask HandleApiError(HttpResponseMessage response, bool ignoreNotFound)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound && ignoreNotFound)
|
||||
return;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var apiError = TryParseApiError(body);
|
||||
if (apiError != null)
|
||||
_logger.Warning("Discord API error: {DiscordErrorCode} {DiscordErrorMessage}", apiError.Code,
|
||||
apiError.Message);
|
||||
|
||||
throw CreateDiscordException(response, body, apiError);
|
||||
}
|
||||
|
||||
private DiscordRequestException CreateDiscordException(HttpResponseMessage response, string body,
|
||||
DiscordApiError? apiError)
|
||||
{
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => new BadRequestException(response, body, apiError),
|
||||
HttpStatusCode.Forbidden => new ForbiddenException(response, body, apiError),
|
||||
HttpStatusCode.Unauthorized => new UnauthorizedException(response, body, apiError),
|
||||
HttpStatusCode.NotFound => new NotFoundException(response, body, apiError),
|
||||
HttpStatusCode.Conflict => new ConflictException(response, body, apiError),
|
||||
HttpStatusCode.TooManyRequests => new TooManyRequestsException(response, body, apiError),
|
||||
_ => new UnknownDiscordRequestException(response, body, apiError)
|
||||
};
|
||||
}
|
||||
|
||||
private DiscordApiError? TryParseApiError(string responseBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseBody))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<DiscordApiError>(responseBody, _jsonSerializerOptions);
|
||||
}
|
||||
catch (JsonException e)
|
||||
{
|
||||
_logger.Verbose(e, "Error deserializing API error");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string NormalizeRoutePath(string url)
|
||||
{
|
||||
url = Regex.Replace(url, @"/channels/\d+", "/channels/{channel_id}");
|
||||
url = Regex.Replace(url, @"/messages/\d+", "/messages/{message_id}");
|
||||
url = Regex.Replace(url, @"/members/\d+", "/members/{user_id}");
|
||||
url = Regex.Replace(url, @"/webhooks/\d+/[^/]+", "/webhooks/{webhook_id}/{webhook_token}");
|
||||
url = Regex.Replace(url, @"/webhooks/\d+", "/webhooks/{webhook_id}");
|
||||
url = Regex.Replace(url, @"/users/\d+", "/users/{user_id}");
|
||||
url = Regex.Replace(url, @"/bans/\d+", "/bans/{user_id}");
|
||||
url = Regex.Replace(url, @"/roles/\d+", "/roles/{role_id}");
|
||||
url = Regex.Replace(url, @"/pins/\d+", "/pins/{message_id}");
|
||||
url = Regex.Replace(url, @"/emojis/\d+", "/emojis/{emoji_id}");
|
||||
url = Regex.Replace(url, @"/guilds/\d+", "/guilds/{guild_id}");
|
||||
url = Regex.Replace(url, @"/integrations/\d+", "/integrations/{integration_id}");
|
||||
url = Regex.Replace(url, @"/permissions/\d+", "/permissions/{overwrite_id}");
|
||||
url = Regex.Replace(url, @"/reactions/[^{/]+/\d+", "/reactions/{emoji}/{user_id}");
|
||||
url = Regex.Replace(url, @"/reactions/[^{/]+", "/reactions/{emoji}");
|
||||
url = Regex.Replace(url, @"/invites/[^{/]+", "/invites/{invite_code}");
|
||||
url = Regex.Replace(url, @"/interactions/\d+/[^{/]+", "/interactions/{interaction_id}/{interaction_token}");
|
||||
url = Regex.Replace(url, @"/interactions/\d+", "/interactions/{interaction_id}");
|
||||
|
||||
// catch-all for missed IDs
|
||||
url = Regex.Replace(url, @"\d{17,19}", "{snowflake}");
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private string GetEndpointMetricsName(HttpRequestMessage req)
|
||||
{
|
||||
var localPath = Regex.Replace(req.RequestUri!.LocalPath, @"/api/v\d+", "");
|
||||
var routePath = NormalizeRoutePath(localPath);
|
||||
return $"{req.Method} {routePath}";
|
||||
}
|
||||
|
||||
private string CleanForLogging(Uri uri)
|
||||
{
|
||||
var path = uri.ToString();
|
||||
|
||||
// don't show tokens in logs
|
||||
// todo: anything missing here?
|
||||
path = Regex.Replace(path, @"/webhooks/(\d+)/[^/]+", "/webhooks/$1/:token");
|
||||
path = Regex.Replace(path, @"/interactions/(\d+)/[^{/]+", "/interactions/$1/:token");
|
||||
|
||||
// remove base URL
|
||||
path = path.Substring(_baseUrl.Length);
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Myriad.Rest.Types;
|
||||
using Myriad.Rest.Types.Requests;
|
||||
@@ -8,143 +6,146 @@ using Myriad.Types;
|
||||
|
||||
using Serilog;
|
||||
|
||||
namespace Myriad.Rest
|
||||
namespace Myriad.Rest;
|
||||
|
||||
public class DiscordApiClient
|
||||
{
|
||||
public class DiscordApiClient
|
||||
public const string UserAgent = "DiscordBot (https://github.com/xSke/PluralKit/tree/main/Myriad/, v1)";
|
||||
private const string DefaultApiBaseUrl = "https://discord.com/api/v9";
|
||||
private readonly BaseRestClient _client;
|
||||
|
||||
public EventHandler<(string, int, long)> OnResponseEvent;
|
||||
|
||||
public DiscordApiClient(string token, ILogger logger, string? baseUrl = null)
|
||||
{
|
||||
public const string UserAgent = "DiscordBot (https://github.com/xSke/PluralKit/tree/main/Myriad/, v1)";
|
||||
private const string DefaultApiBaseUrl = "https://discord.com/api/v9";
|
||||
private readonly BaseRestClient _client;
|
||||
|
||||
public DiscordApiClient(string token, ILogger logger, string? baseUrl = null)
|
||||
{
|
||||
_client = new BaseRestClient(UserAgent, token, logger, baseUrl ?? DefaultApiBaseUrl);
|
||||
_client.OnResponseEvent += (_, ev) => OnResponseEvent?.Invoke(null, ev);
|
||||
}
|
||||
|
||||
public EventHandler<(string, int, long)> OnResponseEvent;
|
||||
|
||||
public Task<GatewayInfo> GetGateway() =>
|
||||
_client.Get<GatewayInfo>("/gateway", ("GetGateway", default))!;
|
||||
|
||||
public Task<GatewayInfo.Bot> GetGatewayBot() =>
|
||||
_client.Get<GatewayInfo.Bot>("/gateway/bot", ("GetGatewayBot", default))!;
|
||||
|
||||
public Task<Channel?> GetChannel(ulong channelId) =>
|
||||
_client.Get<Channel>($"/channels/{channelId}", ("GetChannel", channelId));
|
||||
|
||||
public Task<Message?> GetMessage(ulong channelId, ulong messageId) =>
|
||||
_client.Get<Message>($"/channels/{channelId}/messages/{messageId}", ("GetMessage", channelId));
|
||||
|
||||
public Task<Guild?> GetGuild(ulong id) =>
|
||||
_client.Get<Guild>($"/guilds/{id}", ("GetGuild", id));
|
||||
|
||||
public Task<Channel[]> GetGuildChannels(ulong id) =>
|
||||
_client.Get<Channel[]>($"/guilds/{id}/channels", ("GetGuildChannels", id))!;
|
||||
|
||||
public Task<User?> GetUser(ulong id) =>
|
||||
_client.Get<User>($"/users/{id}", ("GetUser", default));
|
||||
|
||||
public Task<GuildMember?> GetGuildMember(ulong guildId, ulong userId) =>
|
||||
_client.Get<GuildMember>($"/guilds/{guildId}/members/{userId}",
|
||||
("GetGuildMember", guildId));
|
||||
|
||||
public Task<Message> CreateMessage(ulong channelId, MessageRequest request, MultipartFile[]? files = null) =>
|
||||
_client.PostMultipart<Message>($"/channels/{channelId}/messages", ("CreateMessage", channelId), request, files)!;
|
||||
|
||||
public Task<Message> EditMessage(ulong channelId, ulong messageId, MessageEditRequest request) =>
|
||||
_client.Patch<Message>($"/channels/{channelId}/messages/{messageId}", ("EditMessage", channelId), request)!;
|
||||
|
||||
public Task DeleteMessage(ulong channelId, ulong messageId) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}", ("DeleteMessage", channelId));
|
||||
public Task DeleteMessage(Message message) =>
|
||||
_client.Delete($"/channels/{message.ChannelId}/messages/{message.Id}", ("DeleteMessage", message.ChannelId));
|
||||
public Task CreateReaction(ulong channelId, ulong messageId, Emoji emoji) =>
|
||||
_client.Put<object>($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/@me",
|
||||
("CreateReaction", channelId), null);
|
||||
|
||||
public Task DeleteOwnReaction(ulong channelId, ulong messageId, Emoji emoji) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/@me",
|
||||
("DeleteOwnReaction", channelId));
|
||||
|
||||
public Task DeleteUserReaction(ulong channelId, ulong messageId, Emoji emoji, ulong userId) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/{userId}",
|
||||
("DeleteUserReaction", channelId));
|
||||
|
||||
public Task DeleteAllReactions(ulong channelId, ulong messageId) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions",
|
||||
("DeleteAllReactions", channelId));
|
||||
|
||||
public Task DeleteAllReactionsForEmoji(ulong channelId, ulong messageId, Emoji emoji) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}",
|
||||
("DeleteAllReactionsForEmoji", channelId));
|
||||
|
||||
public Task<ApplicationCommand> CreateGlobalApplicationCommand(ulong applicationId,
|
||||
ApplicationCommandRequest request) =>
|
||||
_client.Post<ApplicationCommand>($"/applications/{applicationId}/commands",
|
||||
("CreateGlobalApplicationCommand", applicationId), request)!;
|
||||
|
||||
public Task<ApplicationCommand[]> GetGuildApplicationCommands(ulong applicationId, ulong guildId) =>
|
||||
_client.Get<ApplicationCommand[]>($"/applications/{applicationId}/guilds/{guildId}/commands",
|
||||
("GetGuildApplicationCommands", applicationId))!;
|
||||
|
||||
public Task<ApplicationCommand> CreateGuildApplicationCommand(ulong applicationId, ulong guildId,
|
||||
ApplicationCommandRequest request) =>
|
||||
_client.Post<ApplicationCommand>($"/applications/{applicationId}/guilds/{guildId}/commands",
|
||||
("CreateGuildApplicationCommand", applicationId), request)!;
|
||||
|
||||
public Task<ApplicationCommand> EditGuildApplicationCommand(ulong applicationId, ulong guildId,
|
||||
ApplicationCommandRequest request) =>
|
||||
_client.Patch<ApplicationCommand>($"/applications/{applicationId}/guilds/{guildId}/commands",
|
||||
("EditGuildApplicationCommand", applicationId), request)!;
|
||||
|
||||
public Task DeleteGuildApplicationCommand(ulong applicationId, ulong commandId) =>
|
||||
_client.Delete($"/applications/{applicationId}/commands/{commandId}",
|
||||
("DeleteGuildApplicationCommand", applicationId));
|
||||
|
||||
public Task CreateInteractionResponse(ulong interactionId, string token, InteractionResponse response) =>
|
||||
_client.Post<object>($"/interactions/{interactionId}/{token}/callback",
|
||||
("CreateInteractionResponse", interactionId), response);
|
||||
|
||||
public Task ModifyGuildMember(ulong guildId, ulong userId, ModifyGuildMemberRequest request) =>
|
||||
_client.Patch<object>($"/guilds/{guildId}/members/{userId}",
|
||||
("ModifyGuildMember", guildId), request);
|
||||
|
||||
public Task<Webhook> CreateWebhook(ulong channelId, CreateWebhookRequest request) =>
|
||||
_client.Post<Webhook>($"/channels/{channelId}/webhooks", ("CreateWebhook", channelId), request)!;
|
||||
|
||||
public Task<Webhook> GetWebhook(ulong webhookId) =>
|
||||
_client.Get<Webhook>($"/webhooks/{webhookId}/webhooks", ("GetWebhook", webhookId))!;
|
||||
|
||||
public Task<Webhook[]> GetChannelWebhooks(ulong channelId) =>
|
||||
_client.Get<Webhook[]>($"/channels/{channelId}/webhooks", ("GetChannelWebhooks", channelId))!;
|
||||
|
||||
public Task<Message> ExecuteWebhook(ulong webhookId, string webhookToken, ExecuteWebhookRequest request,
|
||||
MultipartFile[]? files = null, ulong? threadId = null)
|
||||
{
|
||||
var url = $"/webhooks/{webhookId}/{webhookToken}?wait=true";
|
||||
if (threadId != null)
|
||||
url += $"&thread_id={threadId}";
|
||||
|
||||
return _client.PostMultipart<Message>(url,
|
||||
("ExecuteWebhook", webhookId), request, files)!;
|
||||
}
|
||||
|
||||
public Task<Message> EditWebhookMessage(ulong webhookId, string webhookToken, ulong messageId,
|
||||
WebhookMessageEditRequest request, ulong? threadId = null)
|
||||
{
|
||||
var url = $"/webhooks/{webhookId}/{webhookToken}/messages/{messageId}";
|
||||
if (threadId != null)
|
||||
url += $"?thread_id={threadId}";
|
||||
|
||||
return _client.Patch<Message>(url, ("EditWebhookMessage", webhookId), request)!;
|
||||
}
|
||||
|
||||
public Task<Channel> CreateDm(ulong recipientId) =>
|
||||
_client.Post<Channel>($"/users/@me/channels", ("CreateDM", default), new CreateDmRequest(recipientId))!;
|
||||
|
||||
private static string EncodeEmoji(Emoji emoji) =>
|
||||
WebUtility.UrlEncode(emoji.Id != null ? $"{emoji.Name}:{emoji.Id}" : emoji.Name) ??
|
||||
throw new ArgumentException("Could not encode emoji");
|
||||
_client = new BaseRestClient(UserAgent, token, logger, baseUrl ?? DefaultApiBaseUrl);
|
||||
_client.OnResponseEvent += (_, ev) => OnResponseEvent?.Invoke(null, ev);
|
||||
}
|
||||
|
||||
public Task<GatewayInfo> GetGateway() =>
|
||||
_client.Get<GatewayInfo>("/gateway", ("GetGateway", default))!;
|
||||
|
||||
public Task<GatewayInfo.Bot> GetGatewayBot() =>
|
||||
_client.Get<GatewayInfo.Bot>("/gateway/bot", ("GetGatewayBot", default))!;
|
||||
|
||||
public Task<Channel?> GetChannel(ulong channelId) =>
|
||||
_client.Get<Channel>($"/channels/{channelId}", ("GetChannel", channelId));
|
||||
|
||||
public Task<Message?> GetMessage(ulong channelId, ulong messageId) =>
|
||||
_client.Get<Message>($"/channels/{channelId}/messages/{messageId}", ("GetMessage", channelId));
|
||||
|
||||
public Task<Guild?> GetGuild(ulong id) =>
|
||||
_client.Get<Guild>($"/guilds/{id}", ("GetGuild", id));
|
||||
|
||||
public Task<Channel[]> GetGuildChannels(ulong id) =>
|
||||
_client.Get<Channel[]>($"/guilds/{id}/channels", ("GetGuildChannels", id))!;
|
||||
|
||||
public Task<User?> GetUser(ulong id) =>
|
||||
_client.Get<User>($"/users/{id}", ("GetUser", default));
|
||||
|
||||
public Task<GuildMember?> GetGuildMember(ulong guildId, ulong userId) =>
|
||||
_client.Get<GuildMember>($"/guilds/{guildId}/members/{userId}",
|
||||
("GetGuildMember", guildId));
|
||||
|
||||
public Task<Message> CreateMessage(ulong channelId, MessageRequest request, MultipartFile[]? files = null) =>
|
||||
_client.PostMultipart<Message>($"/channels/{channelId}/messages", ("CreateMessage", channelId), request,
|
||||
files)!;
|
||||
|
||||
public Task<Message> EditMessage(ulong channelId, ulong messageId, MessageEditRequest request) =>
|
||||
_client.Patch<Message>($"/channels/{channelId}/messages/{messageId}", ("EditMessage", channelId), request)!;
|
||||
|
||||
public Task DeleteMessage(ulong channelId, ulong messageId) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}", ("DeleteMessage", channelId));
|
||||
|
||||
public Task DeleteMessage(Message message) =>
|
||||
_client.Delete($"/channels/{message.ChannelId}/messages/{message.Id}",
|
||||
("DeleteMessage", message.ChannelId));
|
||||
|
||||
public Task CreateReaction(ulong channelId, ulong messageId, Emoji emoji) =>
|
||||
_client.Put<object>($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/@me",
|
||||
("CreateReaction", channelId), null);
|
||||
|
||||
public Task DeleteOwnReaction(ulong channelId, ulong messageId, Emoji emoji) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/@me",
|
||||
("DeleteOwnReaction", channelId));
|
||||
|
||||
public Task DeleteUserReaction(ulong channelId, ulong messageId, Emoji emoji, ulong userId) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}/{userId}",
|
||||
("DeleteUserReaction", channelId));
|
||||
|
||||
public Task DeleteAllReactions(ulong channelId, ulong messageId) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions",
|
||||
("DeleteAllReactions", channelId));
|
||||
|
||||
public Task DeleteAllReactionsForEmoji(ulong channelId, ulong messageId, Emoji emoji) =>
|
||||
_client.Delete($"/channels/{channelId}/messages/{messageId}/reactions/{EncodeEmoji(emoji)}",
|
||||
("DeleteAllReactionsForEmoji", channelId));
|
||||
|
||||
public Task<ApplicationCommand> CreateGlobalApplicationCommand(ulong applicationId,
|
||||
ApplicationCommandRequest request) =>
|
||||
_client.Post<ApplicationCommand>($"/applications/{applicationId}/commands",
|
||||
("CreateGlobalApplicationCommand", applicationId), request)!;
|
||||
|
||||
public Task<ApplicationCommand[]> GetGuildApplicationCommands(ulong applicationId, ulong guildId) =>
|
||||
_client.Get<ApplicationCommand[]>($"/applications/{applicationId}/guilds/{guildId}/commands",
|
||||
("GetGuildApplicationCommands", applicationId))!;
|
||||
|
||||
public Task<ApplicationCommand> CreateGuildApplicationCommand(ulong applicationId, ulong guildId,
|
||||
ApplicationCommandRequest request) =>
|
||||
_client.Post<ApplicationCommand>($"/applications/{applicationId}/guilds/{guildId}/commands",
|
||||
("CreateGuildApplicationCommand", applicationId), request)!;
|
||||
|
||||
public Task<ApplicationCommand> EditGuildApplicationCommand(ulong applicationId, ulong guildId,
|
||||
ApplicationCommandRequest request) =>
|
||||
_client.Patch<ApplicationCommand>($"/applications/{applicationId}/guilds/{guildId}/commands",
|
||||
("EditGuildApplicationCommand", applicationId), request)!;
|
||||
|
||||
public Task DeleteGuildApplicationCommand(ulong applicationId, ulong commandId) =>
|
||||
_client.Delete($"/applications/{applicationId}/commands/{commandId}",
|
||||
("DeleteGuildApplicationCommand", applicationId));
|
||||
|
||||
public Task CreateInteractionResponse(ulong interactionId, string token, InteractionResponse response) =>
|
||||
_client.Post<object>($"/interactions/{interactionId}/{token}/callback",
|
||||
("CreateInteractionResponse", interactionId), response);
|
||||
|
||||
public Task ModifyGuildMember(ulong guildId, ulong userId, ModifyGuildMemberRequest request) =>
|
||||
_client.Patch<object>($"/guilds/{guildId}/members/{userId}",
|
||||
("ModifyGuildMember", guildId), request);
|
||||
|
||||
public Task<Webhook> CreateWebhook(ulong channelId, CreateWebhookRequest request) =>
|
||||
_client.Post<Webhook>($"/channels/{channelId}/webhooks", ("CreateWebhook", channelId), request)!;
|
||||
|
||||
public Task<Webhook> GetWebhook(ulong webhookId) =>
|
||||
_client.Get<Webhook>($"/webhooks/{webhookId}/webhooks", ("GetWebhook", webhookId))!;
|
||||
|
||||
public Task<Webhook[]> GetChannelWebhooks(ulong channelId) =>
|
||||
_client.Get<Webhook[]>($"/channels/{channelId}/webhooks", ("GetChannelWebhooks", channelId))!;
|
||||
|
||||
public Task<Message> ExecuteWebhook(ulong webhookId, string webhookToken, ExecuteWebhookRequest request,
|
||||
MultipartFile[]? files = null, ulong? threadId = null)
|
||||
{
|
||||
var url = $"/webhooks/{webhookId}/{webhookToken}?wait=true";
|
||||
if (threadId != null)
|
||||
url += $"&thread_id={threadId}";
|
||||
|
||||
return _client.PostMultipart<Message>(url,
|
||||
("ExecuteWebhook", webhookId), request, files)!;
|
||||
}
|
||||
|
||||
public Task<Message> EditWebhookMessage(ulong webhookId, string webhookToken, ulong messageId,
|
||||
WebhookMessageEditRequest request, ulong? threadId = null)
|
||||
{
|
||||
var url = $"/webhooks/{webhookId}/{webhookToken}/messages/{messageId}";
|
||||
if (threadId != null)
|
||||
url += $"?thread_id={threadId}";
|
||||
|
||||
return _client.Patch<Message>(url, ("EditWebhookMessage", webhookId), request)!;
|
||||
}
|
||||
|
||||
public Task<Channel> CreateDm(ulong recipientId) =>
|
||||
_client.Post<Channel>("/users/@me/channels", ("CreateDM", default), new CreateDmRequest(recipientId))!;
|
||||
|
||||
private static string EncodeEmoji(Emoji emoji) =>
|
||||
WebUtility.UrlEncode(emoji.Id != null ? $"{emoji.Name}:{emoji.Id}" : emoji.Name) ??
|
||||
throw new ArgumentException("Could not encode emoji");
|
||||
}
|
@@ -1,9 +1,8 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Myriad.Rest
|
||||
namespace Myriad.Rest;
|
||||
|
||||
public record DiscordApiError(string Message, int Code)
|
||||
{
|
||||
public record DiscordApiError(string Message, int Code)
|
||||
{
|
||||
public JsonElement? Errors { get; init; }
|
||||
}
|
||||
public JsonElement? Errors { get; init; }
|
||||
}
|
@@ -1,77 +1,75 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Myriad.Rest.Exceptions
|
||||
namespace Myriad.Rest.Exceptions;
|
||||
|
||||
public class DiscordRequestException: Exception
|
||||
{
|
||||
public class DiscordRequestException: Exception
|
||||
public DiscordRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError)
|
||||
{
|
||||
public DiscordRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError)
|
||||
{
|
||||
ResponseBody = responseBody;
|
||||
Response = response;
|
||||
ApiError = apiError;
|
||||
}
|
||||
|
||||
public string ResponseBody { get; init; } = null!;
|
||||
public HttpResponseMessage Response { get; init; } = null!;
|
||||
|
||||
public HttpStatusCode StatusCode => Response.StatusCode;
|
||||
public int? ErrorCode => ApiError?.Code;
|
||||
|
||||
internal DiscordApiError? ApiError { get; init; }
|
||||
|
||||
public override string Message =>
|
||||
(ApiError?.Message ?? Response.ReasonPhrase ?? "") + (FormError != null ? $": {FormError}" : "");
|
||||
|
||||
public string? FormError => ApiError?.Errors?.ToString();
|
||||
ResponseBody = responseBody;
|
||||
Response = response;
|
||||
ApiError = apiError;
|
||||
}
|
||||
|
||||
public class NotFoundException: DiscordRequestException
|
||||
{
|
||||
public NotFoundException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
public string ResponseBody { get; init; } = null!;
|
||||
public HttpResponseMessage Response { get; init; } = null!;
|
||||
|
||||
public HttpStatusCode StatusCode => Response.StatusCode;
|
||||
public int? ErrorCode => ApiError?.Code;
|
||||
|
||||
internal DiscordApiError? ApiError { get; init; }
|
||||
|
||||
public override string Message =>
|
||||
(ApiError?.Message ?? Response.ReasonPhrase ?? "") + (FormError != null ? $": {FormError}" : "");
|
||||
|
||||
public string? FormError => ApiError?.Errors?.ToString();
|
||||
}
|
||||
|
||||
public class NotFoundException: DiscordRequestException
|
||||
{
|
||||
public NotFoundException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
|
||||
public class UnauthorizedException: DiscordRequestException
|
||||
{
|
||||
public UnauthorizedException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) :
|
||||
base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
{ }
|
||||
}
|
||||
|
||||
public class UnauthorizedException: DiscordRequestException
|
||||
{
|
||||
public UnauthorizedException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
public class ForbiddenException: DiscordRequestException
|
||||
{
|
||||
public ForbiddenException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
|
||||
public class ForbiddenException: DiscordRequestException
|
||||
{
|
||||
public ForbiddenException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
public class ConflictException: DiscordRequestException
|
||||
{
|
||||
public ConflictException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
|
||||
public class ConflictException: DiscordRequestException
|
||||
{
|
||||
public ConflictException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
public class BadRequestException: DiscordRequestException
|
||||
{
|
||||
public BadRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
|
||||
public class BadRequestException: DiscordRequestException
|
||||
{
|
||||
public BadRequestException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) : base(
|
||||
response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
public class TooManyRequestsException: DiscordRequestException
|
||||
{
|
||||
public TooManyRequestsException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) :
|
||||
base(response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
|
||||
public class TooManyRequestsException: DiscordRequestException
|
||||
{
|
||||
public TooManyRequestsException(HttpResponseMessage response, string responseBody, DiscordApiError? apiError) :
|
||||
base(response, responseBody, apiError)
|
||||
{ }
|
||||
}
|
||||
|
||||
public class UnknownDiscordRequestException: DiscordRequestException
|
||||
{
|
||||
public UnknownDiscordRequestException(HttpResponseMessage response, string responseBody,
|
||||
DiscordApiError? apiError) : base(response, responseBody, apiError) { }
|
||||
}
|
||||
public class UnknownDiscordRequestException: DiscordRequestException
|
||||
{
|
||||
public UnknownDiscordRequestException(HttpResponseMessage response, string responseBody,
|
||||
DiscordApiError? apiError) : base(response, responseBody, apiError) { }
|
||||
}
|
@@ -1,29 +1,26 @@
|
||||
using System;
|
||||
|
||||
using Myriad.Rest.Ratelimit;
|
||||
|
||||
namespace Myriad.Rest.Exceptions
|
||||
namespace Myriad.Rest.Exceptions;
|
||||
|
||||
public class RatelimitException: Exception
|
||||
{
|
||||
public class RatelimitException: Exception
|
||||
public RatelimitException(string? message) : base(message) { }
|
||||
}
|
||||
|
||||
public class RatelimitBucketExhaustedException: RatelimitException
|
||||
{
|
||||
public RatelimitBucketExhaustedException(Bucket bucket, TimeSpan retryAfter) : base(
|
||||
"Rate limit bucket exhausted, request blocked")
|
||||
{
|
||||
public RatelimitException(string? message) : base(message) { }
|
||||
Bucket = bucket;
|
||||
RetryAfter = retryAfter;
|
||||
}
|
||||
|
||||
public class RatelimitBucketExhaustedException: RatelimitException
|
||||
{
|
||||
public RatelimitBucketExhaustedException(Bucket bucket, TimeSpan retryAfter) : base(
|
||||
"Rate limit bucket exhausted, request blocked")
|
||||
{
|
||||
Bucket = bucket;
|
||||
RetryAfter = retryAfter;
|
||||
}
|
||||
public Bucket Bucket { get; }
|
||||
public TimeSpan RetryAfter { get; }
|
||||
}
|
||||
|
||||
public Bucket Bucket { get; }
|
||||
public TimeSpan RetryAfter { get; }
|
||||
}
|
||||
|
||||
public class GloballyRatelimitedException: RatelimitException
|
||||
{
|
||||
public GloballyRatelimitedException() : base("Global rate limit hit") { }
|
||||
}
|
||||
public class GloballyRatelimitedException: RatelimitException
|
||||
{
|
||||
public GloballyRatelimitedException() : base("Global rate limit hit") { }
|
||||
}
|
@@ -1,173 +1,172 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
using Serilog;
|
||||
|
||||
namespace Myriad.Rest.Ratelimit
|
||||
namespace Myriad.Rest.Ratelimit;
|
||||
|
||||
public class Bucket
|
||||
{
|
||||
public class Bucket
|
||||
private static readonly TimeSpan Epsilon = TimeSpan.FromMilliseconds(10);
|
||||
private static readonly TimeSpan FallbackDelay = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
private static readonly TimeSpan StaleTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
private bool _hasReceivedHeaders;
|
||||
|
||||
private DateTimeOffset? _nextReset;
|
||||
private bool _resetTimeValid;
|
||||
|
||||
public Bucket(ILogger logger, string key, ulong major, int limit)
|
||||
{
|
||||
private static readonly TimeSpan Epsilon = TimeSpan.FromMilliseconds(10);
|
||||
private static readonly TimeSpan FallbackDelay = TimeSpan.FromMilliseconds(200);
|
||||
_logger = logger.ForContext<Bucket>();
|
||||
|
||||
private static readonly TimeSpan StaleTimeout = TimeSpan.FromSeconds(5);
|
||||
Key = key;
|
||||
Major = major;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
Limit = limit;
|
||||
Remaining = limit;
|
||||
_resetTimeValid = false;
|
||||
}
|
||||
|
||||
private DateTimeOffset? _nextReset;
|
||||
private bool _resetTimeValid;
|
||||
private bool _hasReceivedHeaders;
|
||||
public string Key { get; }
|
||||
public ulong Major { get; }
|
||||
|
||||
public Bucket(ILogger logger, string key, ulong major, int limit)
|
||||
public int Remaining { get; private set; }
|
||||
|
||||
public int Limit { get; private set; }
|
||||
|
||||
public DateTimeOffset LastUsed { get; private set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public bool TryAcquire()
|
||||
{
|
||||
LastUsed = DateTimeOffset.Now;
|
||||
|
||||
try
|
||||
{
|
||||
_logger = logger.ForContext<Bucket>();
|
||||
_semaphore.Wait();
|
||||
|
||||
Key = key;
|
||||
Major = major;
|
||||
|
||||
Limit = limit;
|
||||
Remaining = limit;
|
||||
_resetTimeValid = false;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public ulong Major { get; }
|
||||
|
||||
public int Remaining { get; private set; }
|
||||
|
||||
public int Limit { get; private set; }
|
||||
|
||||
public DateTimeOffset LastUsed { get; private set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public bool TryAcquire()
|
||||
{
|
||||
LastUsed = DateTimeOffset.Now;
|
||||
|
||||
try
|
||||
if (Remaining > 0)
|
||||
{
|
||||
_semaphore.Wait();
|
||||
|
||||
if (Remaining > 0)
|
||||
{
|
||||
_logger.Verbose(
|
||||
"{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit} left], allowing through",
|
||||
Key, Major, Remaining, Limit);
|
||||
Remaining--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.Debug("{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit}] left, denying",
|
||||
_logger.Verbose(
|
||||
"{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit} left], allowing through",
|
||||
Key, Major, Remaining, Limit);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
Remaining--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.Debug("{BucketKey}/{BucketMajor}: Bucket has [{BucketRemaining}/{BucketLimit}] left, denying",
|
||||
Key, Major, Remaining, Limit);
|
||||
return false;
|
||||
}
|
||||
|
||||
public void HandleResponse(RatelimitHeaders headers)
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
_semaphore.Wait();
|
||||
|
||||
_logger.Verbose("{BucketKey}/{BucketMajor}: Received rate limit headers: {@RateLimitHeaders}",
|
||||
Key, Major, headers);
|
||||
|
||||
if (headers.ResetAfter != null)
|
||||
{
|
||||
var headerNextReset = DateTimeOffset.UtcNow + headers.ResetAfter.Value; // todo: server time
|
||||
if (_nextReset == null || headerNextReset > _nextReset)
|
||||
{
|
||||
_logger.Verbose("{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server (after: {NextResetAfter}, remaining: {Remaining}, local remaining: {LocalRemaining})",
|
||||
Key, Major, headerNextReset, headers.ResetAfter.Value, headers.Remaining, Remaining);
|
||||
|
||||
_nextReset = headerNextReset;
|
||||
_resetTimeValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers.Limit != null)
|
||||
Limit = headers.Limit.Value;
|
||||
|
||||
if (headers.Remaining != null && !_hasReceivedHeaders)
|
||||
{
|
||||
var oldRemaining = Remaining;
|
||||
Remaining = Math.Min(headers.Remaining.Value, Remaining);
|
||||
|
||||
_logger.Debug("{BucketKey}/{BucketMajor}: Received first remaining of {HeaderRemaining}, previous local remaining is {LocalRemaining}, new local remaining is {Remaining}",
|
||||
Key, Major, headers.Remaining.Value, oldRemaining, Remaining);
|
||||
_hasReceivedHeaders = true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(DateTimeOffset now)
|
||||
{
|
||||
try
|
||||
{
|
||||
_semaphore.Wait();
|
||||
|
||||
// If we don't have any reset data, "snap" it to now
|
||||
// This happens before first request and at this point the reset is invalid anyway, so it's fine
|
||||
// but it ensures the stale timeout doesn't trigger early by using `default` value
|
||||
if (_nextReset == null)
|
||||
_nextReset = now;
|
||||
|
||||
// If we're past the reset time *and* we haven't reset already, do that
|
||||
var timeSinceReset = now - _nextReset;
|
||||
var shouldReset = _resetTimeValid && timeSinceReset > TimeSpan.Zero;
|
||||
if (shouldReset)
|
||||
{
|
||||
_logger.Debug("{BucketKey}/{BucketMajor}: Bucket timed out, refreshing with {BucketLimit} requests",
|
||||
Key, Major, Limit);
|
||||
Remaining = Limit;
|
||||
_resetTimeValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// We've run out of requests without having any new reset time,
|
||||
// *and* it's been longer than a set amount - add one request back to the pool and hope that one returns
|
||||
var isBucketStale = !_resetTimeValid && Remaining <= 0 && timeSinceReset > StaleTimeout;
|
||||
if (isBucketStale)
|
||||
{
|
||||
_logger.Warning(
|
||||
"{BucketKey}/{BucketMajor}: Bucket is stale ({StaleTimeout} passed with no rate limit info), allowing one request through",
|
||||
Key, Major, StaleTimeout);
|
||||
|
||||
Remaining = 1;
|
||||
|
||||
// Reset the (still-invalid) reset time to now, so we don't keep hitting this conditional over and over...
|
||||
_nextReset = now;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan GetResetDelay(DateTimeOffset now)
|
||||
{
|
||||
// If we don't have a valid reset time, return the fallback delay always
|
||||
// (so it'll keep spinning until we hopefully have one...)
|
||||
if (!_resetTimeValid)
|
||||
return FallbackDelay;
|
||||
|
||||
var delay = (_nextReset ?? now) - now;
|
||||
|
||||
// If we have a really small (or negative) value, return a fallback delay too
|
||||
if (delay < Epsilon)
|
||||
return FallbackDelay;
|
||||
|
||||
return delay;
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleResponse(RatelimitHeaders headers)
|
||||
{
|
||||
try
|
||||
{
|
||||
_semaphore.Wait();
|
||||
|
||||
_logger.Verbose("{BucketKey}/{BucketMajor}: Received rate limit headers: {@RateLimitHeaders}",
|
||||
Key, Major, headers);
|
||||
|
||||
if (headers.ResetAfter != null)
|
||||
{
|
||||
var headerNextReset = DateTimeOffset.UtcNow + headers.ResetAfter.Value; // todo: server time
|
||||
if (_nextReset == null || headerNextReset > _nextReset)
|
||||
{
|
||||
_logger.Verbose(
|
||||
"{BucketKey}/{BucketMajor}: Received reset time {NextReset} from server (after: {NextResetAfter}, remaining: {Remaining}, local remaining: {LocalRemaining})",
|
||||
Key, Major, headerNextReset, headers.ResetAfter.Value, headers.Remaining, Remaining
|
||||
);
|
||||
|
||||
_nextReset = headerNextReset;
|
||||
_resetTimeValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers.Limit != null)
|
||||
Limit = headers.Limit.Value;
|
||||
|
||||
if (headers.Remaining != null && !_hasReceivedHeaders)
|
||||
{
|
||||
var oldRemaining = Remaining;
|
||||
Remaining = Math.Min(headers.Remaining.Value, Remaining);
|
||||
|
||||
_logger.Debug(
|
||||
"{BucketKey}/{BucketMajor}: Received first remaining of {HeaderRemaining}, previous local remaining is {LocalRemaining}, new local remaining is {Remaining}",
|
||||
Key, Major, headers.Remaining.Value, oldRemaining, Remaining);
|
||||
_hasReceivedHeaders = true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(DateTimeOffset now)
|
||||
{
|
||||
try
|
||||
{
|
||||
_semaphore.Wait();
|
||||
|
||||
// If we don't have any reset data, "snap" it to now
|
||||
// This happens before first request and at this point the reset is invalid anyway, so it's fine
|
||||
// but it ensures the stale timeout doesn't trigger early by using `default` value
|
||||
if (_nextReset == null)
|
||||
_nextReset = now;
|
||||
|
||||
// If we're past the reset time *and* we haven't reset already, do that
|
||||
var timeSinceReset = now - _nextReset;
|
||||
var shouldReset = _resetTimeValid && timeSinceReset > TimeSpan.Zero;
|
||||
if (shouldReset)
|
||||
{
|
||||
_logger.Debug("{BucketKey}/{BucketMajor}: Bucket timed out, refreshing with {BucketLimit} requests",
|
||||
Key, Major, Limit);
|
||||
Remaining = Limit;
|
||||
_resetTimeValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// We've run out of requests without having any new reset time,
|
||||
// *and* it's been longer than a set amount - add one request back to the pool and hope that one returns
|
||||
var isBucketStale = !_resetTimeValid && Remaining <= 0 && timeSinceReset > StaleTimeout;
|
||||
if (isBucketStale)
|
||||
{
|
||||
_logger.Warning(
|
||||
"{BucketKey}/{BucketMajor}: Bucket is stale ({StaleTimeout} passed with no rate limit info), allowing one request through",
|
||||
Key, Major, StaleTimeout);
|
||||
|
||||
Remaining = 1;
|
||||
|
||||
// Reset the (still-invalid) reset time to now, so we don't keep hitting this conditional over and over...
|
||||
_nextReset = now;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan GetResetDelay(DateTimeOffset now)
|
||||
{
|
||||
// If we don't have a valid reset time, return the fallback delay always
|
||||
// (so it'll keep spinning until we hopefully have one...)
|
||||
if (!_resetTimeValid)
|
||||
return FallbackDelay;
|
||||
|
||||
var delay = (_nextReset ?? now) - now;
|
||||
|
||||
// If we have a really small (or negative) value, return a fallback delay too
|
||||
if (delay < Epsilon)
|
||||
return FallbackDelay;
|
||||
|
||||
return delay;
|
||||
}
|
||||
}
|
@@ -1,82 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Serilog;
|
||||
|
||||
namespace Myriad.Rest.Ratelimit
|
||||
namespace Myriad.Rest.Ratelimit;
|
||||
|
||||
public class BucketManager: IDisposable
|
||||
{
|
||||
public class BucketManager: IDisposable
|
||||
private static readonly TimeSpan StaleBucketTimeout = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan PruneWorkerInterval = TimeSpan.FromMinutes(1);
|
||||
private readonly ConcurrentDictionary<(string key, ulong major), Bucket> _buckets = new();
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _endpointKeyMap = new();
|
||||
private readonly ConcurrentDictionary<string, int> _knownKeyLimits = new();
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly Task _worker;
|
||||
private readonly CancellationTokenSource _workerCts = new();
|
||||
|
||||
public BucketManager(ILogger logger)
|
||||
{
|
||||
private static readonly TimeSpan StaleBucketTimeout = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan PruneWorkerInterval = TimeSpan.FromMinutes(1);
|
||||
private readonly ConcurrentDictionary<(string key, ulong major), Bucket> _buckets = new();
|
||||
_logger = logger.ForContext<BucketManager>();
|
||||
_worker = PruneWorker(_workerCts.Token);
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _endpointKeyMap = new();
|
||||
private readonly ConcurrentDictionary<string, int> _knownKeyLimits = new();
|
||||
public void Dispose()
|
||||
{
|
||||
_workerCts.Dispose();
|
||||
_worker.Dispose();
|
||||
}
|
||||
|
||||
private readonly ILogger _logger;
|
||||
public Bucket? GetBucket(string endpoint, ulong major)
|
||||
{
|
||||
if (!_endpointKeyMap.TryGetValue(endpoint, out var key))
|
||||
return null;
|
||||
|
||||
private readonly Task _worker;
|
||||
private readonly CancellationTokenSource _workerCts = new();
|
||||
if (_buckets.TryGetValue((key, major), out var bucket))
|
||||
return bucket;
|
||||
|
||||
public BucketManager(ILogger logger)
|
||||
if (!_knownKeyLimits.TryGetValue(key, out var knownLimit))
|
||||
return null;
|
||||
|
||||
_logger.Debug("Creating new bucket {BucketKey}/{BucketMajor} with limit {KnownLimit}", key, major,
|
||||
knownLimit);
|
||||
return _buckets.GetOrAdd((key, major),
|
||||
k => new Bucket(_logger, k.Item1, k.Item2, knownLimit));
|
||||
}
|
||||
|
||||
public void UpdateEndpointInfo(string endpoint, string key, int? limit)
|
||||
{
|
||||
_endpointKeyMap[endpoint] = key;
|
||||
|
||||
if (limit != null)
|
||||
_knownKeyLimits[key] = limit.Value;
|
||||
}
|
||||
|
||||
private async Task PruneWorker(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
_logger = logger.ForContext<BucketManager>();
|
||||
_worker = PruneWorker(_workerCts.Token);
|
||||
await Task.Delay(PruneWorkerInterval, ct);
|
||||
PruneStaleBuckets(DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
private void PruneStaleBuckets(DateTimeOffset now)
|
||||
{
|
||||
foreach (var (key, bucket) in _buckets)
|
||||
{
|
||||
_workerCts.Dispose();
|
||||
_worker.Dispose();
|
||||
}
|
||||
if (now - bucket.LastUsed <= StaleBucketTimeout)
|
||||
continue;
|
||||
|
||||
public Bucket? GetBucket(string endpoint, ulong major)
|
||||
{
|
||||
if (!_endpointKeyMap.TryGetValue(endpoint, out var key))
|
||||
return null;
|
||||
|
||||
if (_buckets.TryGetValue((key, major), out var bucket))
|
||||
return bucket;
|
||||
|
||||
if (!_knownKeyLimits.TryGetValue(key, out var knownLimit))
|
||||
return null;
|
||||
|
||||
_logger.Debug("Creating new bucket {BucketKey}/{BucketMajor} with limit {KnownLimit}", key, major, knownLimit);
|
||||
return _buckets.GetOrAdd((key, major),
|
||||
k => new Bucket(_logger, k.Item1, k.Item2, knownLimit));
|
||||
}
|
||||
|
||||
public void UpdateEndpointInfo(string endpoint, string key, int? limit)
|
||||
{
|
||||
_endpointKeyMap[endpoint] = key;
|
||||
|
||||
if (limit != null)
|
||||
_knownKeyLimits[key] = limit.Value;
|
||||
}
|
||||
|
||||
private async Task PruneWorker(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(PruneWorkerInterval, ct);
|
||||
PruneStaleBuckets(DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
private void PruneStaleBuckets(DateTimeOffset now)
|
||||
{
|
||||
foreach (var (key, bucket) in _buckets)
|
||||
{
|
||||
if (now - bucket.LastUsed <= StaleBucketTimeout)
|
||||
continue;
|
||||
|
||||
_logger.Debug("Pruning unused bucket {BucketKey}/{BucketMajor} (last used at {BucketLastUsed})",
|
||||
bucket.Key, bucket.Major, bucket.LastUsed);
|
||||
_buckets.TryRemove(key, out _);
|
||||
}
|
||||
_logger.Debug("Pruning unused bucket {BucketKey}/{BucketMajor} (last used at {BucketLastUsed})",
|
||||
bucket.Key, bucket.Major, bucket.LastUsed);
|
||||
_buckets.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,46 +1,40 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Polly;
|
||||
|
||||
namespace Myriad.Rest.Ratelimit
|
||||
namespace Myriad.Rest.Ratelimit;
|
||||
|
||||
public class DiscordRateLimitPolicy: AsyncPolicy<HttpResponseMessage>
|
||||
{
|
||||
public class DiscordRateLimitPolicy: AsyncPolicy<HttpResponseMessage>
|
||||
public const string EndpointContextKey = "Endpoint";
|
||||
public const string MajorContextKey = "Major";
|
||||
|
||||
private readonly Ratelimiter _ratelimiter;
|
||||
|
||||
public DiscordRateLimitPolicy(Ratelimiter ratelimiter, PolicyBuilder<HttpResponseMessage>? policyBuilder = null)
|
||||
: base(policyBuilder)
|
||||
{
|
||||
public const string EndpointContextKey = "Endpoint";
|
||||
public const string MajorContextKey = "Major";
|
||||
_ratelimiter = ratelimiter;
|
||||
}
|
||||
|
||||
private readonly Ratelimiter _ratelimiter;
|
||||
protected override async Task<HttpResponseMessage> ImplementationAsync(
|
||||
Func<Context, CancellationToken, Task<HttpResponseMessage>> action, Context context, CancellationToken ct,
|
||||
bool continueOnCapturedContext)
|
||||
{
|
||||
if (!context.TryGetValue(EndpointContextKey, out var endpointObj) || !(endpointObj is string endpoint))
|
||||
throw new ArgumentException("Must provide endpoint in Polly context");
|
||||
|
||||
public DiscordRateLimitPolicy(Ratelimiter ratelimiter, PolicyBuilder<HttpResponseMessage>? policyBuilder = null)
|
||||
: base(policyBuilder)
|
||||
{
|
||||
_ratelimiter = ratelimiter;
|
||||
}
|
||||
if (!context.TryGetValue(MajorContextKey, out var majorObj) || !(majorObj is ulong major))
|
||||
throw new ArgumentException("Must provide major in Polly context");
|
||||
|
||||
protected override async Task<HttpResponseMessage> ImplementationAsync(
|
||||
Func<Context, CancellationToken, Task<HttpResponseMessage>> action, Context context, CancellationToken ct,
|
||||
bool continueOnCapturedContext)
|
||||
{
|
||||
if (!context.TryGetValue(EndpointContextKey, out var endpointObj) || !(endpointObj is string endpoint))
|
||||
throw new ArgumentException("Must provide endpoint in Polly context");
|
||||
// Check rate limit, throw if we're not allowed...
|
||||
_ratelimiter.AllowRequestOrThrow(endpoint, major, DateTimeOffset.Now);
|
||||
|
||||
if (!context.TryGetValue(MajorContextKey, out var majorObj) || !(majorObj is ulong major))
|
||||
throw new ArgumentException("Must provide major in Polly context");
|
||||
// We're OK, push it through
|
||||
var response = await action(context, ct).ConfigureAwait(continueOnCapturedContext);
|
||||
|
||||
// Check rate limit, throw if we're not allowed...
|
||||
_ratelimiter.AllowRequestOrThrow(endpoint, major, DateTimeOffset.Now);
|
||||
// Update rate limit state with headers
|
||||
var headers = RatelimitHeaders.Parse(response);
|
||||
_ratelimiter.HandleResponse(headers, endpoint, major);
|
||||
|
||||
// We're OK, push it through
|
||||
var response = await action(context, ct).ConfigureAwait(continueOnCapturedContext);
|
||||
|
||||
// Update rate limit state with headers
|
||||
var headers = RatelimitHeaders.Parse(response);
|
||||
_ratelimiter.HandleResponse(headers, endpoint, major);
|
||||
|
||||
return response;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
@@ -1,85 +1,79 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Myriad.Rest.Ratelimit
|
||||
namespace Myriad.Rest.Ratelimit;
|
||||
|
||||
public record RatelimitHeaders
|
||||
{
|
||||
public record RatelimitHeaders
|
||||
private const string LimitHeader = "X-RateLimit-Limit";
|
||||
private const string RemainingHeader = "X-RateLimit-Remaining";
|
||||
private const string ResetHeader = "X-RateLimit-Reset";
|
||||
private const string ResetAfterHeader = "X-RateLimit-Reset-After";
|
||||
private const string BucketHeader = "X-RateLimit-Bucket";
|
||||
private const string GlobalHeader = "X-RateLimit-Global";
|
||||
|
||||
public bool Global { get; private set; }
|
||||
public int? Limit { get; private set; }
|
||||
public int? Remaining { get; private set; }
|
||||
public DateTimeOffset? Reset { get; private set; }
|
||||
public TimeSpan? ResetAfter { get; private set; }
|
||||
public string? Bucket { get; private set; }
|
||||
|
||||
public DateTimeOffset? ServerDate { get; private set; }
|
||||
|
||||
public bool HasRatelimitInfo =>
|
||||
Limit != null && Remaining != null && Reset != null && ResetAfter != null && Bucket != null;
|
||||
|
||||
public static RatelimitHeaders Parse(HttpResponseMessage response)
|
||||
{
|
||||
private const string LimitHeader = "X-RateLimit-Limit";
|
||||
private const string RemainingHeader = "X-RateLimit-Remaining";
|
||||
private const string ResetHeader = "X-RateLimit-Reset";
|
||||
private const string ResetAfterHeader = "X-RateLimit-Reset-After";
|
||||
private const string BucketHeader = "X-RateLimit-Bucket";
|
||||
private const string GlobalHeader = "X-RateLimit-Global";
|
||||
|
||||
public bool Global { get; private set; }
|
||||
public int? Limit { get; private set; }
|
||||
public int? Remaining { get; private set; }
|
||||
public DateTimeOffset? Reset { get; private set; }
|
||||
public TimeSpan? ResetAfter { get; private set; }
|
||||
public string? Bucket { get; private set; }
|
||||
|
||||
public DateTimeOffset? ServerDate { get; private set; }
|
||||
|
||||
public bool HasRatelimitInfo =>
|
||||
Limit != null && Remaining != null && Reset != null && ResetAfter != null && Bucket != null;
|
||||
|
||||
public RatelimitHeaders() { }
|
||||
|
||||
public static RatelimitHeaders Parse(HttpResponseMessage response)
|
||||
var headers = new RatelimitHeaders
|
||||
{
|
||||
var headers = new RatelimitHeaders
|
||||
{
|
||||
ServerDate = response.Headers.Date,
|
||||
Limit = TryGetInt(response, LimitHeader),
|
||||
Remaining = TryGetInt(response, RemainingHeader),
|
||||
Bucket = TryGetHeader(response, BucketHeader)
|
||||
};
|
||||
ServerDate = response.Headers.Date,
|
||||
Limit = TryGetInt(response, LimitHeader),
|
||||
Remaining = TryGetInt(response, RemainingHeader),
|
||||
Bucket = TryGetHeader(response, BucketHeader)
|
||||
};
|
||||
|
||||
|
||||
var resetTimestamp = TryGetDouble(response, ResetHeader);
|
||||
if (resetTimestamp != null)
|
||||
headers.Reset = DateTimeOffset.FromUnixTimeMilliseconds((long)(resetTimestamp.Value * 1000));
|
||||
var resetTimestamp = TryGetDouble(response, ResetHeader);
|
||||
if (resetTimestamp != null)
|
||||
headers.Reset = DateTimeOffset.FromUnixTimeMilliseconds((long)(resetTimestamp.Value * 1000));
|
||||
|
||||
var resetAfterSeconds = TryGetDouble(response, ResetAfterHeader);
|
||||
if (resetAfterSeconds != null)
|
||||
headers.ResetAfter = TimeSpan.FromSeconds(resetAfterSeconds.Value);
|
||||
var resetAfterSeconds = TryGetDouble(response, ResetAfterHeader);
|
||||
if (resetAfterSeconds != null)
|
||||
headers.ResetAfter = TimeSpan.FromSeconds(resetAfterSeconds.Value);
|
||||
|
||||
var global = TryGetHeader(response, GlobalHeader);
|
||||
if (global != null && bool.TryParse(global, out var globalBool))
|
||||
headers.Global = globalBool;
|
||||
var global = TryGetHeader(response, GlobalHeader);
|
||||
if (global != null && bool.TryParse(global, out var globalBool))
|
||||
headers.Global = globalBool;
|
||||
|
||||
return headers;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static string? TryGetHeader(HttpResponseMessage response, string headerName)
|
||||
{
|
||||
if (!response.Headers.TryGetValues(headerName, out var values))
|
||||
return null;
|
||||
private static string? TryGetHeader(HttpResponseMessage response, string headerName)
|
||||
{
|
||||
if (!response.Headers.TryGetValues(headerName, out var values))
|
||||
return null;
|
||||
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int? TryGetInt(HttpResponseMessage response, string headerName)
|
||||
{
|
||||
var valueString = TryGetHeader(response, headerName);
|
||||
private static int? TryGetInt(HttpResponseMessage response, string headerName)
|
||||
{
|
||||
var valueString = TryGetHeader(response, headerName);
|
||||
|
||||
if (!int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
return null;
|
||||
if (!int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static double? TryGetDouble(HttpResponseMessage response, string headerName)
|
||||
{
|
||||
var valueString = TryGetHeader(response, headerName);
|
||||
private static double? TryGetDouble(HttpResponseMessage response, string headerName)
|
||||
{
|
||||
var valueString = TryGetHeader(response, headerName);
|
||||
|
||||
if (!double.TryParse(valueString, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
|
||||
return null;
|
||||
if (!double.TryParse(valueString, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
|
||||
return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
@@ -1,86 +1,83 @@
|
||||
using System;
|
||||
|
||||
using Myriad.Rest.Exceptions;
|
||||
|
||||
using Serilog;
|
||||
|
||||
namespace Myriad.Rest.Ratelimit
|
||||
namespace Myriad.Rest.Ratelimit;
|
||||
|
||||
public class Ratelimiter: IDisposable
|
||||
{
|
||||
public class Ratelimiter: IDisposable
|
||||
private readonly BucketManager _buckets;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private DateTimeOffset? _globalRateLimitExpiry;
|
||||
|
||||
public Ratelimiter(ILogger logger)
|
||||
{
|
||||
private readonly BucketManager _buckets;
|
||||
private readonly ILogger _logger;
|
||||
_logger = logger.ForContext<Ratelimiter>();
|
||||
_buckets = new BucketManager(logger);
|
||||
}
|
||||
|
||||
private DateTimeOffset? _globalRateLimitExpiry;
|
||||
public void Dispose()
|
||||
{
|
||||
_buckets.Dispose();
|
||||
}
|
||||
|
||||
public Ratelimiter(ILogger logger)
|
||||
public void AllowRequestOrThrow(string endpoint, ulong major, DateTimeOffset now)
|
||||
{
|
||||
if (IsGloballyRateLimited(now))
|
||||
{
|
||||
_logger = logger.ForContext<Ratelimiter>();
|
||||
_buckets = new BucketManager(logger);
|
||||
_logger.Warning("Globally rate limited until {GlobalRateLimitExpiry}, cancelling request",
|
||||
_globalRateLimitExpiry);
|
||||
throw new GloballyRatelimitedException();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
var bucket = _buckets.GetBucket(endpoint, major);
|
||||
if (bucket == null)
|
||||
{
|
||||
_buckets.Dispose();
|
||||
// No rate limit for this endpoint (yet), allow through
|
||||
_logger.Debug("No rate limit data for endpoint {Endpoint}, allowing through", endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
public void AllowRequestOrThrow(string endpoint, ulong major, DateTimeOffset now)
|
||||
bucket.Tick(now);
|
||||
|
||||
if (bucket.TryAcquire())
|
||||
// We're allowed to send it! :)
|
||||
return;
|
||||
|
||||
// We can't send this request right now; retrying...
|
||||
var waitTime = bucket.GetResetDelay(now);
|
||||
|
||||
// add a small buffer for Timing:tm:
|
||||
waitTime += TimeSpan.FromMilliseconds(50);
|
||||
|
||||
// (this is caught by a WaitAndRetry Polly handler, if configured)
|
||||
throw new RatelimitBucketExhaustedException(bucket, waitTime);
|
||||
}
|
||||
|
||||
public void HandleResponse(RatelimitHeaders headers, string endpoint, ulong major)
|
||||
{
|
||||
if (!headers.HasRatelimitInfo)
|
||||
return;
|
||||
|
||||
// TODO: properly calculate server time?
|
||||
if (headers.Global)
|
||||
{
|
||||
if (IsGloballyRateLimited(now))
|
||||
{
|
||||
_logger.Warning("Globally rate limited until {GlobalRateLimitExpiry}, cancelling request",
|
||||
_globalRateLimitExpiry);
|
||||
throw new GloballyRatelimitedException();
|
||||
}
|
||||
_logger.Warning(
|
||||
"Global rate limit hit, resetting at {GlobalRateLimitExpiry} (in {GlobalRateLimitResetAfter}!",
|
||||
_globalRateLimitExpiry, headers.ResetAfter);
|
||||
_globalRateLimitExpiry = headers.Reset;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update buckets first, then get it again, to properly "transfer" this info over to the new value
|
||||
_buckets.UpdateEndpointInfo(endpoint, headers.Bucket!, headers.Limit);
|
||||
|
||||
var bucket = _buckets.GetBucket(endpoint, major);
|
||||
if (bucket == null)
|
||||
{
|
||||
// No rate limit for this endpoint (yet), allow through
|
||||
_logger.Debug("No rate limit data for endpoint {Endpoint}, allowing through", endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.Tick(now);
|
||||
|
||||
if (bucket.TryAcquire())
|
||||
// We're allowed to send it! :)
|
||||
return;
|
||||
|
||||
// We can't send this request right now; retrying...
|
||||
var waitTime = bucket.GetResetDelay(now);
|
||||
|
||||
// add a small buffer for Timing:tm:
|
||||
waitTime += TimeSpan.FromMilliseconds(50);
|
||||
|
||||
// (this is caught by a WaitAndRetry Polly handler, if configured)
|
||||
throw new RatelimitBucketExhaustedException(bucket, waitTime);
|
||||
bucket?.HandleResponse(headers);
|
||||
}
|
||||
|
||||
public void HandleResponse(RatelimitHeaders headers, string endpoint, ulong major)
|
||||
{
|
||||
if (!headers.HasRatelimitInfo)
|
||||
return;
|
||||
|
||||
// TODO: properly calculate server time?
|
||||
if (headers.Global)
|
||||
{
|
||||
_logger.Warning(
|
||||
"Global rate limit hit, resetting at {GlobalRateLimitExpiry} (in {GlobalRateLimitResetAfter}!",
|
||||
_globalRateLimitExpiry, headers.ResetAfter);
|
||||
_globalRateLimitExpiry = headers.Reset;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update buckets first, then get it again, to properly "transfer" this info over to the new value
|
||||
_buckets.UpdateEndpointInfo(endpoint, headers.Bucket!, headers.Limit);
|
||||
|
||||
var bucket = _buckets.GetBucket(endpoint, major);
|
||||
bucket?.HandleResponse(headers);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsGloballyRateLimited(DateTimeOffset now) =>
|
||||
_globalRateLimitExpiry > now;
|
||||
}
|
||||
|
||||
private bool IsGloballyRateLimited(DateTimeOffset now) =>
|
||||
_globalRateLimitExpiry > now;
|
||||
}
|
@@ -2,21 +2,20 @@ using System.Text.Json.Serialization;
|
||||
|
||||
using Myriad.Serialization;
|
||||
|
||||
namespace Myriad.Rest.Types
|
||||
{
|
||||
public record AllowedMentions
|
||||
{
|
||||
[JsonConverter(typeof(JsonSnakeCaseStringEnumConverter))]
|
||||
public enum ParseType
|
||||
{
|
||||
Roles,
|
||||
Users,
|
||||
Everyone
|
||||
}
|
||||
namespace Myriad.Rest.Types;
|
||||
|
||||
public ParseType[]? Parse { get; set; }
|
||||
public ulong[]? Users { get; set; }
|
||||
public ulong[]? Roles { get; set; }
|
||||
public bool RepliedUser { get; set; }
|
||||
public record AllowedMentions
|
||||
{
|
||||
[JsonConverter(typeof(JsonSnakeCaseStringEnumConverter))]
|
||||
public enum ParseType
|
||||
{
|
||||
Roles,
|
||||
Users,
|
||||
Everyone
|
||||
}
|
||||
|
||||
public ParseType[]? Parse { get; set; }
|
||||
public ulong[]? Users { get; set; }
|
||||
public ulong[]? Roles { get; set; }
|
||||
public bool RepliedUser { get; set; }
|
||||
}
|
@@ -1,6 +1,3 @@
|
||||
using System.IO;
|
||||
namespace Myriad.Rest.Types;
|
||||
|
||||
namespace Myriad.Rest.Types
|
||||
{
|
||||
public record MultipartFile(string Filename, Stream Data, string? Description);
|
||||
}
|
||||
public record MultipartFile(string Filename, Stream Data, string? Description);
|
@@ -1,13 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Myriad.Types;
|
||||
|
||||
namespace Myriad.Rest.Types
|
||||
namespace Myriad.Rest.Types;
|
||||
|
||||
public record ApplicationCommandRequest
|
||||
{
|
||||
public record ApplicationCommandRequest
|
||||
{
|
||||
public string Name { get; init; }
|
||||
public string Description { get; init; }
|
||||
public List<ApplicationCommandOption>? Options { get; init; }
|
||||
}
|
||||
public string Name { get; init; }
|
||||
public string Description { get; init; }
|
||||
public List<ApplicationCommandOption>? Options { get; init; }
|
||||
}
|
@@ -1,4 +1,3 @@
|
||||
namespace Myriad.Rest.Types.Requests
|
||||
{
|
||||
public record CreateDmRequest(ulong RecipientId);
|
||||
}
|
||||
namespace Myriad.Rest.Types.Requests;
|
||||
|
||||
public record CreateDmRequest(ulong RecipientId);
|
@@ -1,4 +1,3 @@
|
||||
namespace Myriad.Rest.Types.Requests
|
||||
{
|
||||
public record CreateWebhookRequest(string Name);
|
||||
}
|
||||
namespace Myriad.Rest.Types.Requests;
|
||||
|
||||
public record CreateWebhookRequest(string Name);
|
@@ -1,14 +1,13 @@
|
||||
using Myriad.Types;
|
||||
|
||||
namespace Myriad.Rest.Types.Requests
|
||||
namespace Myriad.Rest.Types.Requests;
|
||||
|
||||
public record ExecuteWebhookRequest
|
||||
{
|
||||
public record ExecuteWebhookRequest
|
||||
{
|
||||
public string? Content { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? AvatarUrl { get; init; }
|
||||
public Embed[] Embeds { get; init; }
|
||||
public Message.Attachment[] Attachments { get; set; }
|
||||
public AllowedMentions? AllowedMentions { get; init; }
|
||||
}
|
||||
public string? Content { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? AvatarUrl { get; init; }
|
||||
public Embed[] Embeds { get; init; }
|
||||
public Message.Attachment[] Attachments { get; set; }
|
||||
public AllowedMentions? AllowedMentions { get; init; }
|
||||
}
|
@@ -3,23 +3,22 @@ using System.Text.Json.Serialization;
|
||||
using Myriad.Types;
|
||||
using Myriad.Utils;
|
||||
|
||||
namespace Myriad.Rest.Types.Requests
|
||||
namespace Myriad.Rest.Types.Requests;
|
||||
|
||||
public record MessageEditRequest
|
||||
{
|
||||
public record MessageEditRequest
|
||||
{
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<string?> Content { get; init; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<string?> Content { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<Embed?> Embed { get; init; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<Embed?> Embed { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<Message.MessageFlags> Flags { get; init; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<Message.MessageFlags> Flags { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<AllowedMentions> AllowedMentions { get; init; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<AllowedMentions> AllowedMentions { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<MessageComponent[]?> Components { get; init; }
|
||||
}
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<MessageComponent[]?> Components { get; init; }
|
||||
}
|
@@ -1,14 +1,13 @@
|
||||
using Myriad.Types;
|
||||
|
||||
namespace Myriad.Rest.Types.Requests
|
||||
namespace Myriad.Rest.Types.Requests;
|
||||
|
||||
public record MessageRequest
|
||||
{
|
||||
public record MessageRequest
|
||||
{
|
||||
public string? Content { get; set; }
|
||||
public object? Nonce { get; set; }
|
||||
public bool Tts { get; set; }
|
||||
public AllowedMentions? AllowedMentions { get; set; }
|
||||
public Embed? Embed { get; set; }
|
||||
public MessageComponent[]? Components { get; set; }
|
||||
}
|
||||
public string? Content { get; set; }
|
||||
public object? Nonce { get; set; }
|
||||
public bool Tts { get; set; }
|
||||
public AllowedMentions? AllowedMentions { get; set; }
|
||||
public Embed? Embed { get; set; }
|
||||
public MessageComponent[]? Components { get; set; }
|
||||
}
|
@@ -1,7 +1,6 @@
|
||||
namespace Myriad.Rest.Types
|
||||
namespace Myriad.Rest.Types;
|
||||
|
||||
public record ModifyGuildMemberRequest
|
||||
{
|
||||
public record ModifyGuildMemberRequest
|
||||
{
|
||||
public string? Nick { get; init; }
|
||||
}
|
||||
public string? Nick { get; init; }
|
||||
}
|
@@ -2,14 +2,13 @@ using System.Text.Json.Serialization;
|
||||
|
||||
using Myriad.Utils;
|
||||
|
||||
namespace Myriad.Rest.Types.Requests
|
||||
{
|
||||
public record WebhookMessageEditRequest
|
||||
{
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<string?> Content { get; init; }
|
||||
namespace Myriad.Rest.Types.Requests;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<AllowedMentions> AllowedMentions { get; init; }
|
||||
}
|
||||
public record WebhookMessageEditRequest
|
||||
{
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<string?> Content { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public Optional<AllowedMentions> AllowedMentions { get; init; }
|
||||
}
|
Reference in New Issue
Block a user