initial commit; very basic REST client

This commit is contained in:
sam 2024-07-02 01:14:46 +02:00
commit 6b39228b67
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
18 changed files with 1177 additions and 0 deletions

View file

@ -0,0 +1,69 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using NodaTime;
namespace Foxcord.Discord;
[JsonConverter(typeof(SnowflakeJsonConverter))]
public readonly struct Snowflake(ulong value)
{
public const long Epoch = 1_420_070_400_000; // 2015-01-01 at 00:00:00 UTC
public readonly ulong Value = value;
/// <summary>
/// The time this snowflake was created.
/// </summary>
public Instant Time => Instant.FromUnixTimeMilliseconds(Timestamp);
/// <summary>
/// The Unix timestamp embedded in this snowflake, in milliseconds.
/// </summary>
public long Timestamp => (long)((Value >> 22) + Epoch);
/// <summary>
/// The process ID embedded in this snowflake.
/// </summary>
public byte WorkerId => (byte)((Value & 0x3E0000) >> 17);
/// <summary>
/// The thread ID embedded in this snowflake.
/// </summary>
public byte ProcessId => (byte)((Value & 0x1F000) >> 12);
/// <summary>
/// The increment embedded in this snowflake.
/// </summary>
public short Increment => (short)(Value & 0xFFF);
public static bool operator <(Snowflake arg1, Snowflake arg2) => arg1.Value < arg2.Value;
public static bool operator >(Snowflake arg1, Snowflake arg2) => arg1.Value > arg2.Value;
public static bool operator ==(Snowflake arg1, Snowflake arg2) => arg1.Value == arg2.Value;
public static bool operator !=(Snowflake arg1, Snowflake arg2) => arg1.Value != arg2.Value;
public static implicit operator ulong(Snowflake s) => s.Value;
public static implicit operator long(Snowflake s) => (long)s.Value;
public static implicit operator Snowflake(ulong n) => new(n);
public static implicit operator Snowflake(long n) => new((ulong)n);
public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake)
{
snowflake = null;
if (!ulong.TryParse(input, out var res)) return false;
snowflake = new Snowflake(res);
return true;
}
public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString();
public class SnowflakeJsonConverter : JsonConverter<Snowflake>
{
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(ulong.Parse(reader.GetString()!));
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.Value.ToString());
}
}

26
Foxcord/Discord/User.cs Normal file
View file

@ -0,0 +1,26 @@
namespace Foxcord.Discord;
public record User(
Snowflake Id,
string Username,
string Discriminator,
string? DisplayName,
string? Avatar,
string? Banner,
int? AccentColor,
string? Locale,
bool Bot,
bool System = false
)
{
public string Tag => Discriminator == "0" ? Username : $"{Username}#{Discriminator}";
private string AvatarExt => Avatar?.StartsWith("a_") == true ? ".gif" : ".png";
private string BannerExt => Banner?.StartsWith("a_") == true ? ".gif" : ".png";
public string AvatarUrl => Avatar == null
? $"https://cdn.discordapp.com/embed/avatars/{(Id >> 22) % 6}"
: $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}{AvatarExt}";
public string? BannerUrl => Banner == null ? null : $"https://cdn.discordapp.com/banners/{Id}/{Banner}{BannerExt}";
}

16
Foxcord/DiscordClient.cs Normal file
View file

@ -0,0 +1,16 @@
using Foxcord.Rest;
namespace Foxcord;
/// <summary>
/// A Discord client combining WebSocket gateway and REST API clients.
/// </summary>
public class DiscordClient
{
public DiscordRestClient Rest { get; private init; }
public DiscordClient(DiscordClientOptions options)
{
Rest = new DiscordRestClient($"Bot {options.Token}", options.UserAgent, options.RestBaseUrl);
}
}

View file

@ -0,0 +1,11 @@
namespace Foxcord;
/// <summary>
///
/// </summary>
public class DiscordClientOptions
{
public required string Token { get; init; }
public string? UserAgent { get; init; }
public string? RestBaseUrl { get; init; }
}

14
Foxcord/Foxcord.csproj Normal file
View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NodaTime" Version="3.1.11" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,6 @@
namespace Foxcord;
/// <summary>
/// Internal errors from Foxcord itself. These generally can't be handled by the library consumer.
/// </summary>
public class FoxcordException(string message) : Exception(message);

View file

@ -0,0 +1,9 @@
using Foxcord.Discord;
namespace Foxcord.Rest;
public partial class DiscordRestClient
{
public Task<User> GetUserAsync(ulong id) => RequestAsync<User>(HttpMethod.Get, $"/users/{id}");
public Task<User> GetMeAsync() => RequestAsync<User>(HttpMethod.Get, "/users/@me");
}

View file

@ -0,0 +1,78 @@
using System.Net.Http.Headers;
using Foxcord.Serialization;
namespace Foxcord.Rest;
public partial class DiscordRestClient
{
public string UserAgent { get; private init; } = "DiscordBot (https://codeberg.org/u1f320/Foxcord, v1)";
private readonly HttpClient _client = new();
private readonly string _token;
private readonly string _baseUrl = "https://discord.com/api/v10";
public DiscordRestClient(string token, string? userAgent = null, string? baseUrl = null)
{
_token = token;
if (userAgent != null) UserAgent = userAgent;
if (baseUrl != null) _baseUrl = baseUrl;
}
public async Task<TResponse> RequestAsync<TResponse>(HttpMethod method, string path,
IReadOnlyDictionary<string, string>? extraHeaders = null) where TResponse : class
{
var request = CreateRequest(method, path, body: null, extraHeaders);
var resp = await Send(request);
return await resp.Content.ReadAsJsonAsync<TResponse>() ??
throw new FoxcordException("HTTP response was unexpectedly null");
}
public async Task<TResponse> RequestAsync<TRequest, TResponse>(HttpMethod method, string path, TRequest body,
IReadOnlyDictionary<string, string>? extraHeaders = null)
where TRequest : class where TResponse : class
{
var request = CreateRequest(method, path, body, extraHeaders);
var resp = await Send(request);
return await resp.Content.ReadAsJsonAsync<TResponse>() ??
throw new FoxcordException("HTTP response was unexpectedly null");
}
public async Task RequestAsync<TRequest>(HttpMethod method, string path, TRequest body,
IReadOnlyDictionary<string, string>? extraHeaders = null) where TRequest : class =>
await Send(CreateRequest(method, path, body, extraHeaders));
public async Task RequestAsync(HttpMethod method, string path,
IReadOnlyDictionary<string, string>? extraHeaders = null) =>
await Send(CreateRequest(method, path, body: null, extraHeaders));
// TODO: rate limits
private async Task<HttpResponseMessage> Send(HttpRequestMessage request)
{
var resp = await _client.SendAsync(request);
if (!resp.IsSuccessStatusCode)
{
var error = await resp.Content.ReadAsJsonAsync<RestException.ErrorResponse>();
throw new RestException(resp.StatusCode, error!);
}
return resp;
}
private HttpRequestMessage CreateRequest(HttpMethod method, string path, object? body,
IReadOnlyDictionary<string, string>? extraHeaders = null)
{
var request = new HttpRequestMessage(method, _baseUrl + path);
request.Headers.Add("Authorization", _token);
if (body != null)
{
request.Content = new ReadOnlyMemoryContent(JsonSerializer.SerializeToUtf8Bytes(body));
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json", "utf-8");
}
if (extraHeaders != null)
foreach (var (k, v) in extraHeaders)
request.Headers.Add(k, v);
return request;
}
}

View file

@ -0,0 +1,28 @@
using System.Net;
using System.Text.Json.Nodes;
using Foxcord.Serialization;
namespace Foxcord.Rest;
public class RestException : Exception
{
public HttpStatusCode StatusCode { get; private init; }
public int Code { get; private init; }
public new string Message { get; private init; }
public string? Errors { get; private init; }
internal RestException(HttpStatusCode statusCode, ErrorResponse response)
{
StatusCode = statusCode;
Code = response.Code;
Message = response.Message;
Errors = response.Errors?.ToJsonString();
}
public enum ErrorCode
{
GeneralError = 0
}
internal record ErrorResponse(int Code, string Message, JsonObject? Errors);
}

View file

@ -0,0 +1,27 @@
using System.Net.Http.Json;
using System.Text.Json;
using DefaultJsonSerializer = System.Text.Json.JsonSerializer;
namespace Foxcord.Serialization;
public static class JsonSerializer
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
public static string Serialize(object? value) => DefaultJsonSerializer.Serialize(value, Options);
public static byte[] SerializeToUtf8Bytes(object? value) =>
DefaultJsonSerializer.SerializeToUtf8Bytes(value, Options);
public static TValue? Deserialize<TValue>(string json) => DefaultJsonSerializer.Deserialize<TValue>(json, Options);
public static ValueTask<TValue?> DeserializeAsync<TValue>(Stream json) =>
DefaultJsonSerializer.DeserializeAsync<TValue>(json, Options);
public static Task<TValue?> ReadAsJsonAsync<TValue>(this HttpContent content) =>
content.ReadFromJsonAsync<TValue>(Options);
}