diff --git a/extra/admin-api/Models/Spacebar.Models.Api/CreateChannelRequest.cs b/extra/admin-api/Models/Spacebar.Models.Api/CreateChannelRequest.cs new file mode 100644 index 0000000..abd280c --- /dev/null +++ b/extra/admin-api/Models/Spacebar.Models.Api/CreateChannelRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Spacebar.Models.Api; + +public class CreateChannelRequest { + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("position")] + public int? Position { get; set; } + + [JsonPropertyName("type")] + public int? Type { get; set; } // TODO enum + + // + [JsonPropertyName("parent_id"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public long? ParentId { get; set; } + // TODO: rest of schema +} \ No newline at end of file diff --git a/extra/admin-api/Models/Spacebar.Models.Api/CreateGuildRequest.cs b/extra/admin-api/Models/Spacebar.Models.Api/CreateGuildRequest.cs new file mode 100644 index 0000000..1bb8079 --- /dev/null +++ b/extra/admin-api/Models/Spacebar.Models.Api/CreateGuildRequest.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Spacebar.Models.Api; + +public class CreateGuildRequest { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("region")] + public string? Region { get; set; } // deprecated? + + [JsonPropertyName("icon")] + public string? IconData { get; set; } + + [JsonPropertyName("verification_level")] + public int? VerificationLevel { get; set; } // TODO enum + + [JsonPropertyName("default_message_notifications")] + public int? DefaultMessageNotifications { get; set; } // TODO enum + + [JsonPropertyName("explicit_content_filter")] + public int? ExplicitContentFilter { get; set; } // TODO enum + + [JsonPropertyName("preferred_locale")] + public string? PreferredLocale { get; set; } + + [JsonPropertyName("roles")] + public List Roles { get; set; } // TODO type + + [JsonPropertyName("channels")] + public List Channels { get; set; } // TODO type + + [JsonPropertyName("afk_channel_id"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public long? AfkChannelId { get; set; } + + [JsonPropertyName("afk_timeout")] + public int? AfkTimeout { get; set; } + + [JsonPropertyName("system_channel_id"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public long? SystemChannelId { get; set; } + + [JsonPropertyName("system_channel_flags")] + public int? SystemChannelFlags { get; set; } // TODO enum + + [JsonPropertyName("guild_template_code")] + public string? GuildTemplateCode { get; set; } + + [JsonPropertyName("staff_only")] + public bool? StaffOnly { get; set; } +} \ No newline at end of file diff --git a/extra/admin-api/Models/Spacebar.Models.Api/RegisterRequest.cs b/extra/admin-api/Models/Spacebar.Models.Api/RegisterRequest.cs new file mode 100644 index 0000000..232f5f8 --- /dev/null +++ b/extra/admin-api/Models/Spacebar.Models.Api/RegisterRequest.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Spacebar.Models.Api; + +public class RegisterRequest { + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("password")] + public string? Password { get; set; } + + [JsonPropertyName("consent")] + public bool Consent { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("fingerprint")] + public string? Fingerprint { get; set; } + + [JsonPropertyName("invite")] + public string? Invite { get; set; } + + [JsonPropertyName("date_of_birth")] + public DateTimeOffset? DateOfBirth { get; set; } + + [JsonPropertyName("gift_code_sku_id")] + public string? GiftCodeSkuId { get; set; } + + [JsonPropertyName("promotional_email_opt_in")] + public bool? PromotionalEmailOptIn { get; set; } + + [JsonPropertyName("unique_username_registration")] + public bool? UniqueUsernameRegistration { get; set; } + + [JsonPropertyName("global_name")] + public bool? GlobalName { get; set; } +} + +public class RegisterResponse { + [JsonPropertyName("token")] + public string Token { get; set; } + + [JsonPropertyName("show_verification_form")] + public bool? ShowVerificationForm { get; set; } +} \ No newline at end of file diff --git a/extra/admin-api/Models/Spacebar.Models.Api/SpacebarApiError.cs b/extra/admin-api/Models/Spacebar.Models.Api/SpacebarApiError.cs index 03bf926..27ef7d1 100644 --- a/extra/admin-api/Models/Spacebar.Models.Api/SpacebarApiError.cs +++ b/extra/admin-api/Models/Spacebar.Models.Api/SpacebarApiError.cs @@ -1,29 +1,51 @@ +using System.Text.Json; using System.Text.Json.Nodes; namespace Spacebar.Models.Api; public class SpacebarApiException : Exception { public int Code { get; set; } + + public string ErrorMessage { get; set; } + public string? Request { get; set; } // Spacebar extension + // public Dictionary Errors { get; set; } public JsonObject? Errors { get; set; } + + public JsonObject?[]? AjvErrors { get; set; } public class FieldErrorList { // public } - public SpacebarApiException(string? message) : base(message) { - - } + public SpacebarApiException(string? message) : base(message) { } // TODO: abstract out to HTTP layer public static SpacebarApiException FromJson(JsonObject resp) { - var ex = new SpacebarApiException(resp["message"]!.GetValue()) { + var msg = resp["code"]!.GetValue() + " " + resp["message"]!.GetValue(); + + if (resp.ContainsKey("_ajvErrors") && resp["_ajvErrors"]!.AsArray().Any()) { + msg = msg + " " + resp["_ajvErrors"]!.ToJsonString(new() { WriteIndented = true }); + } else if (resp.ContainsKey("errors") && resp["errors"]!.AsObject().Any()) { + msg = msg + " " + resp["errors"]!.ToJsonString(new() { WriteIndented = true }); + } + + var ex = new SpacebarApiException(msg) { Code = resp["code"]!.GetValue(), + ErrorMessage = resp["message"]!.GetValue(), Request = resp["request"]?.GetValue(), - Errors = resp["errors"]?.AsObject() + Errors = resp["errors"]?.AsObject(), + AjvErrors = resp["_ajvErrors"]?.Deserialize(), }; return ex; } + + public JsonObject AsJsonObject() => new() { + { "message", Message }, + { "code", Code }, + { "request", Request }, + { "errors", Errors } + }; } \ No newline at end of file diff --git a/extra/admin-api/SpacebarAdminAPI.slnx b/extra/admin-api/SpacebarAdminAPI.slnx index 9a6115e..44498b0 100644 --- a/extra/admin-api/SpacebarAdminAPI.slnx +++ b/extra/admin-api/SpacebarAdminAPI.slnx @@ -35,6 +35,7 @@ + diff --git a/extra/admin-api/Utilities/Spacebar.Client/Components/ChannelMessageList.razor b/extra/admin-api/Utilities/Spacebar.Client/Components/ChannelMessageList.razor index 6ac95c6..7fb0024 100644 --- a/extra/admin-api/Utilities/Spacebar.Client/Components/ChannelMessageList.razor +++ b/extra/admin-api/Utilities/Spacebar.Client/Components/ChannelMessageList.razor @@ -3,8 +3,8 @@ @using System.Text.RegularExpressions @using ArcaneLibs.Blazor.Components.Services @using ArcaneLibs.Extensions -@using Spacebar.Client.Core @using Spacebar.Models.Generic +@using Spacebar.Sdk.Core @inject JsConsoleService jsConsole @foreach (var message in Messages) { diff --git a/extra/admin-api/Utilities/Spacebar.Client/Components/ClientManager.razor b/extra/admin-api/Utilities/Spacebar.Client/Components/ClientManager.razor index 353d863..2021b8d 100644 --- a/extra/admin-api/Utilities/Spacebar.Client/Components/ClientManager.razor +++ b/extra/admin-api/Utilities/Spacebar.Client/Components/ClientManager.razor @@ -1,9 +1,9 @@ @using ArcaneLibs.Blazor.Components.Services @using ArcaneLibs.Extensions -@using Spacebar.Client.Core @using Spacebar.Client.WebCore @using Spacebar.Client.WebCore.Client @using Spacebar.Models.Gateway +@using Spacebar.Sdk.Core @inject SessionStore sessionStore @inject SpacebarClientProviderService clientProvider @inject JsConsoleService jsConsole diff --git a/extra/admin-api/Utilities/Spacebar.Client/Core/MarkdownEnumerator.cs b/extra/admin-api/Utilities/Spacebar.Client/Core/MarkdownEnumerator.cs deleted file mode 100644 index 6006813..0000000 --- a/extra/admin-api/Utilities/Spacebar.Client/Core/MarkdownEnumerator.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Spacebar.Client.Core; - -public class MarkdownEnumerator { - public IEnumerable EnumerateMarkdownComponents(string text) { - if (text.StartsWith("-#")) { - var line = text.Split('\n')[0]; - text = text.Replace(line + "\n", ""); - yield return new ContainerMarkdownNode() { - ComponentType = "sub", - Contents = new MarkdownEnumerator().EnumerateMarkdownComponents(line[2..].TrimStart()).ToList() - }; - } - else if (text.StartsWith("#")) { - var hdrLevel = text.TakeWhile(x => x == '#').Count(); - var line = text.Split('\n')[0]; - text = text.Replace(line + "\n", ""); - yield return new ContainerMarkdownNode() { - ComponentType = "h" + hdrLevel, - Contents = new MarkdownEnumerator().EnumerateMarkdownComponents(line[hdrLevel..].TrimStart()).ToList() - }; - } - yield return new InnerTextMarkdownNode(text); - } -} - -public class BaseMarkdownNode { -} - -public class ContainerMarkdownNode : BaseMarkdownNode { - public string ComponentType { get; set; } - public List Contents { get; set; } -} - -public class InnerTextMarkdownNode(string Text) : BaseMarkdownNode{ - public string Text { get; set; } -} diff --git a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClient.cs b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClient.cs deleted file mode 100644 index cd624e5..0000000 --- a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClient.cs +++ /dev/null @@ -1,201 +0,0 @@ -using System.Data.Common; -using System.Diagnostics; -using System.Net.Http.Json; -using System.Net.WebSockets; -using System.Text.Json; -using System.Text.Json.Nodes; -using ArcaneLibs.Extensions; -using Spacebar.Models.Api; -using Spacebar.Models.Gateway; -using Spacebar.Models.Generic; - -namespace Spacebar.Client.Core; - -public class UnauthenticatedSpacebarClient(ILogger logger, SpacebarClientWellKnown wellKnown) { - public async Task LoginAsync(LoginRequest request) { - // TODO: rebase - using var hc = new HttpClient(); - var resp = await hc.PostAsJsonAsync(new Uri(wellKnown.Api.GetApiBaseUrl(), "auth/login"), request); - // TODO: abstract out - if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); - return (await resp.Content.ReadFromJsonAsync())!; - } -} - -public class AuthenticatedSpacebarClient { - private readonly ILogger _logger; - - public AuthenticatedSpacebarClient(ILogger logger, IServiceProvider sp, SpacebarClientWellKnown wellKnown, string token) { - _logger = logger; - ApiHttpClient = new HttpClient() { - BaseAddress = wellKnown.Api.GetApiBaseUrl() - }; - ApiHttpClient.DefaultRequestHeaders.Authorization = new("Bearer", token); - Gateway = new(sp.GetRequiredService>(), wellKnown, token); - ClientWellKnown = wellKnown; - } - - public HttpClient ApiHttpClient { get; set; } - public AuthenticatedSpacebarGatewayClient Gateway { get; set; } - public SpacebarClientWellKnown ClientWellKnown { get; set; } - - // TODO: write a proper full user model... - public async Task GetCurrentUser() { - var resp = await ApiHttpClient.GetAsync("users/@me"); - // TODO: abstract out - if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); - return (await resp.Content.ReadFromJsonAsync())!; - } - - ~AuthenticatedSpacebarClient() { - ApiHttpClient.Dispose(); - } - - public SpacebarClientChannel GetChannel(long channelId) { - return new(this, channelId); - } -} - -public class SpacebarClientChannel(AuthenticatedSpacebarClient client, long channelId) { - public long Id => channelId; - - public async Task> GetMessagesAsync(long? around = null, long? before = null, long? after = null, int limit = 50) { - var uri = $"channels/{channelId}/messages?limit={limit}"; - if (around.HasValue) uri += $"&around={around.Value}"; - if (before.HasValue) uri += $"&before={before.Value}"; - if (after.HasValue) uri += $"&after={after.Value}"; - - var resp = await client.ApiHttpClient.GetAsync(uri); - // TODO: abstract out - if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); - var data = await resp.Content.ReadFromJsonAsync>(); - Console.WriteLine(data.ToJson(indent: false, ignoreNull: true)); - return data.Select(x => x.Deserialize()).ToList(); - } -} - -public class AuthenticatedSpacebarGatewayClient(ILogger logger, SpacebarClientWellKnown wellKnown, string token) { - public ClientWebSocket RawClientWebSocket = new(); - public int Sequence; - public bool TraceGatewayMessages = false; - - public IdentifyRequest IdentifyData { get; } = new() { - Intents = (GatewayIntentFlags?)0xFFFFFFFF, // too lazy to do math, just gimme everything - Capabilities = 0 - }; - - public List> OnGatewayMessage { get; } = []; - public List>> OnceGatewayMessage { get; } = []; - - public async Task Connect() { - if (RawClientWebSocket.State is WebSocketState.Connecting or WebSocketState.Open) return; - Sequence = 0; - await RawClientWebSocket.ConnectAsync(new Uri(wellKnown.Gateway.BaseUrl).AddQuery("encoding", "json"), CancellationToken.None); - } - - public async Task Start() { - await foreach (var msg in _runReceiveLoop()) { - // logger.LogInformation("Got gateway message: {msg}", msg); - if (msg.Opcode == GatewayOpcode.S2CHello) { - _ = _runHeartbeatLoop(msg.GetData()!.HeartbeatInterval).ContinueWith(ct => { - logger.LogWarning("Heartbeat loop exited!"); - if (ct.IsFaulted) throw ct.Exception; - }); - IdentifyData.Token = token; - await RawClientWebSocket.SendAsync(JsonSerializer.SerializeToUtf8Bytes(new GatewayPayload() { - Opcode = GatewayOpcode.C2SIdentify, // Identify - EventData = IdentifyData.ToJsonNode().AsObject() - }), WebSocketMessageType.Text, WebSocketMessageFlags.EndOfMessage, CancellationToken.None); - } - else if (msg.Opcode == GatewayOpcode.S2CHeartbeatAck) { - logger.LogInformation("Got heartbeat ACK from server!"); - } - - await Task.WhenAll(OnGatewayMessage.Select(async x => { - try { - await x(msg); - } - catch (Exception e) { - logger.LogError("OnGatewayMessage callback failed: {e}", e); - } - }).ToArray()); - foreach (var t in OnceGatewayMessage.Select((Func> Callback, Task WasHandled) (cb) => (cb, cb(msg))).ToList()) { - try { - var handled = await t.WasHandled; - if (handled) { - OnceGatewayMessage.Remove(t.Callback); - } - } - catch (Exception e) { - logger.LogError("OnceGatewayMessage callback failed: {e}", e); - } - } - } - } - - private async Task _runHeartbeatLoop(int interval) { - while (RawClientWebSocket.State < WebSocketState.Closed) { - await RawClientWebSocket.SendAsync(JsonSerializer.SerializeToUtf8Bytes(new GatewayPayload() { - Opcode = GatewayOpcode.C2SQoSHeartbeat, // QoS Heartbeat - EventData = new QoSHeartbeatRequest() { - Sequence = Sequence, - QoSPayload = new() { - Active = true, - Reasons = ["foregrounded"], - Version = 27 - } - }.ToJsonNode().AsObject() - }), WebSocketMessageType.Text, WebSocketMessageFlags.EndOfMessage, CancellationToken.None); - await Task.Delay(interval); - } - } - - private const int ReceiveBufferSize = 2 * 1024 * 1024; - - private async IAsyncEnumerable _runReceiveLoop() { - List messageParts = []; - List<(string Name, TimeSpan Elapsed)> trace = []; - var buffer = new byte[ReceiveBufferSize]; - int idx = 0; - - while (RawClientWebSocket.State < WebSocketState.Closed) { - var sw = Stopwatch.StartNew(); - - var msg = await RawClientWebSocket.ReceiveAsync(buffer, CancellationToken.None); - trace.Add(($"RCV.{idx}", sw.GetElapsedAndRestart())); - - Console.WriteLine($"Websocket message chunk read: {msg.MessageType} {msg.Count} {msg.EndOfMessage}"); - messageParts.AddRange(msg.Count < ReceiveBufferSize ? buffer[..msg.Count] : buffer); - trace.Add(($"STO.{idx}({msg.Count})", sw.GetElapsedAndRestart())); - idx++; - - if (msg.EndOfMessage) { - Console.WriteLine($"Got message, deserialising {messageParts.Count} bytes..."); - var fullMsg = messageParts.ToArray(); - trace.Add(($"LD({messageParts.Count})", sw.GetElapsedAndRestart())); - - var d = JsonSerializer.Deserialize(fullMsg); - trace.Add(($"LJS({fullMsg.Length})", sw.GetElapsedAndRestart())); - - Console.WriteLine($"Received gateway message #{d.Sequence}: {(byte)d.Opcode}/{d.Opcode.ToString().Replace("S2C", "")} {d.DispatchEventType}"); - yield return d ?? throw new InvalidDataException("Gateway message deserialisation returned null?"); - trace.Add(($"YLD", sw.GetElapsedAndRestart())); - - if (TraceGatewayMessages) { - Console.WriteLine("Yielded message!"); - Console.WriteLine("Trace:"); - foreach (var t in trace) - Console.WriteLine($" - {t.Name}: {t.Elapsed}"); - } - - messageParts.Clear(); - trace.Clear(); - trace.Add(($"RST", sw.GetElapsedAndRestart())); - } - } - } - - ~AuthenticatedSpacebarGatewayClient() { - RawClientWebSocket.Dispose(); - } -} \ No newline at end of file diff --git a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientProviderService.cs b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientProviderService.cs deleted file mode 100644 index a732c9a..0000000 --- a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientProviderService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ArcaneLibs.Collections; - -namespace Spacebar.Client.Core; - -public class SpacebarClientProviderService(ILogger logger, IServiceProvider serviceProvider, SpacebarClientWellKnownResolverService clientWellKnownResolver) { - private static readonly SemaphoreCache UnauthenticatedClientCache = new(); - private static readonly SemaphoreCache AuthenticatedClientCache = new(); - - public async Task GetUnauthenticatedClientAsync(string serverName) { - return await UnauthenticatedClientCache.GetOrAdd(serverName, async () => { - logger.LogInformation("Creating a new unauthenticated client for {serverName}!", serverName); - var clientLogger = serviceProvider.GetRequiredService>(); - var wellKnown = await clientWellKnownResolver.ResolveClientWellKnown(serverName); - return new UnauthenticatedSpacebarClient(clientLogger, wellKnown); - }); - } - - public async Task GetAuthenticatedClientAsync(string serverName, string accessToken) { - return await AuthenticatedClientCache.GetOrAdd(serverName, async () => { - logger.LogInformation("Creating a new authenticated client for {serverName}!", serverName); - var clientLogger = serviceProvider.GetRequiredService>(); - var wellKnown = await clientWellKnownResolver.ResolveClientWellKnown(serverName); - return new AuthenticatedSpacebarClient(clientLogger, serviceProvider, wellKnown, accessToken); - }); - } -} \ No newline at end of file diff --git a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientWellKnownResolver.cs b/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientWellKnownResolver.cs deleted file mode 100644 index a106435..0000000 --- a/extra/admin-api/Utilities/Spacebar.Client/Core/SpacebarClientWellKnownResolver.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json.Serialization; - -namespace Spacebar.Client.Core; - -public class SpacebarClientWellKnownResolverService(ILogger logger) { - public async Task ResolveClientWellKnown(string serverName) { - using var hc = new HttpClient(); - logger.LogInformation("Resolving .well-known for {serverName}", serverName); - return await hc.GetFromJsonAsync($"https://{serverName}/.well-known/spacebar/client")!; - } -} - -public class SpacebarClientWellKnown { - [JsonPropertyName("api")] - public required ApiWellKnownData Api { get; set; } - - [JsonPropertyName("cdn")] - public required GenericUrlWellKnownData Cdn { get; set; } - - [JsonPropertyName("admin")] - public GenericUrlWellKnownData? Admin { get; set; } - - [JsonPropertyName("gateway")] - public required GatewayWellKnownData Gateway { get; set; } - - public class GenericUrlWellKnownData { - [JsonPropertyName("baseUrl")] - public required string BaseUrl { get; set; } - } - - public class ApiWellKnownData : GenericUrlWellKnownData { - [JsonPropertyName("apiVersions")] - public required ApiVersionsData ApiVersions { get; set; } - - // Utility methods - public Uri GetApiBaseUrl(string? version = null) { - return new Uri(BaseUrl + "/api/v" + (version ?? ApiVersions.Default)); - } - - public class ApiVersionsData { - [JsonPropertyName("default")] - public required string Default { get; set; } - - [JsonPropertyName("active")] - public required List Active { get; set; } - } - } - - public class GatewayWellKnownData : GenericUrlWellKnownData { - [JsonPropertyName("encoding")] - public required List Encoding { get; set; } - - [JsonPropertyName("compression")] - public required List Compression { get; set; } - } -} \ No newline at end of file diff --git a/extra/admin-api/Utilities/Spacebar.Client/Pages/Auth/Login.razor b/extra/admin-api/Utilities/Spacebar.Client/Pages/Auth/Login.razor index a7ab182..63b0c0c 100644 --- a/extra/admin-api/Utilities/Spacebar.Client/Pages/Auth/Login.razor +++ b/extra/admin-api/Utilities/Spacebar.Client/Pages/Auth/Login.razor @@ -1,8 +1,7 @@ @page "/login" @using ArcaneLibs.Blazor.Components -@using ArcaneLibs.Extensions -@using Spacebar.Client.Core @using Spacebar.Client.WebCore +@using Spacebar.Sdk.Core @inject SpacebarClientWellKnownResolverService clientWellKnownResolver @inject SessionStore sessionStore @inject SpacebarClientProviderService clientProvider diff --git a/extra/admin-api/Utilities/Spacebar.Client/Pages/Channels/@me.razor b/extra/admin-api/Utilities/Spacebar.Client/Pages/Channels/@me.razor index 3a47c7b..1939eda 100644 --- a/extra/admin-api/Utilities/Spacebar.Client/Pages/Channels/@me.razor +++ b/extra/admin-api/Utilities/Spacebar.Client/Pages/Channels/@me.razor @@ -1,7 +1,7 @@ @* Workaround for Rider "Bad compile constant value" bug *@ @attribute [Route(PageUri)] @using ArcaneLibs.Blazor.Components.Services -@using Spacebar.Client.Core +@using Spacebar.Sdk.Core @inject JsConsoleService jsConsole

@@me

diff --git a/extra/admin-api/Utilities/Spacebar.Client/Pages/Home.razor b/extra/admin-api/Utilities/Spacebar.Client/Pages/Home.razor index fd39d3f..84ab85a 100644 --- a/extra/admin-api/Utilities/Spacebar.Client/Pages/Home.razor +++ b/extra/admin-api/Utilities/Spacebar.Client/Pages/Home.razor @@ -2,8 +2,8 @@ @page "/app" @using ArcaneLibs.Blazor.Components @using ArcaneLibs.Extensions -@using Spacebar.Client.Core @using Spacebar.Client.WebCore +@using Spacebar.Sdk.Core @inject SpacebarClientWellKnownResolverService cswkrs @inject SessionStore sessionStore diff --git a/extra/admin-api/Utilities/Spacebar.Client/Program.cs b/extra/admin-api/Utilities/Spacebar.Client/Program.cs index 7d7d88e..6a435b8 100644 --- a/extra/admin-api/Utilities/Spacebar.Client/Program.cs +++ b/extra/admin-api/Utilities/Spacebar.Client/Program.cs @@ -2,8 +2,8 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Spacebar.Client; -using Spacebar.Client.Core; using Spacebar.Client.WebCore; +using Spacebar.Sdk.Core; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); diff --git a/extra/admin-api/Utilities/Spacebar.Client/Spacebar.Client.csproj b/extra/admin-api/Utilities/Spacebar.Client/Spacebar.Client.csproj index 1df6e63..95953ac 100644 --- a/extra/admin-api/Utilities/Spacebar.Client/Spacebar.Client.csproj +++ b/extra/admin-api/Utilities/Spacebar.Client/Spacebar.Client.csproj @@ -28,14 +28,15 @@ - - - - + + + + +
diff --git a/extra/admin-api/Utilities/Spacebar.Sdk/Core/MarkdownEnumerator.cs b/extra/admin-api/Utilities/Spacebar.Sdk/Core/MarkdownEnumerator.cs new file mode 100644 index 0000000..041d715 --- /dev/null +++ b/extra/admin-api/Utilities/Spacebar.Sdk/Core/MarkdownEnumerator.cs @@ -0,0 +1,36 @@ +namespace Spacebar.Sdk.Core; + +public class MarkdownEnumerator { + public IEnumerable EnumerateMarkdownComponents(string text) { + if (text.StartsWith("-#")) { + var line = text.Split('\n')[0]; + text = text.Replace(line + "\n", ""); + yield return new ContainerMarkdownNode() { + ComponentType = "sub", + Contents = new MarkdownEnumerator().EnumerateMarkdownComponents(line[2..].TrimStart()).ToList() + }; + } + else if (text.StartsWith("#")) { + var hdrLevel = text.TakeWhile(x => x == '#').Count(); + var line = text.Split('\n')[0]; + text = text.Replace(line + "\n", ""); + yield return new ContainerMarkdownNode() { + ComponentType = "h" + hdrLevel, + Contents = new MarkdownEnumerator().EnumerateMarkdownComponents(line[hdrLevel..].TrimStart()).ToList() + }; + } + yield return new InnerTextMarkdownNode(text); + } +} + +public class BaseMarkdownNode { +} + +public class ContainerMarkdownNode : BaseMarkdownNode { + public string ComponentType { get; set; } + public List Contents { get; set; } +} + +public class InnerTextMarkdownNode(string Text) : BaseMarkdownNode{ + public string Text { get; set; } +} diff --git a/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClient.cs b/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClient.cs new file mode 100644 index 0000000..5843b27 --- /dev/null +++ b/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClient.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using ArcaneLibs.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Spacebar.Models.Api; +using Spacebar.Models.Gateway; +using Spacebar.Models.Generic; + +namespace Spacebar.Sdk.Core; + +public class UnauthenticatedSpacebarClient(ILogger logger, SpacebarClientWellKnown wellKnown) { + public async Task LoginAsync(LoginRequest request) { + // TODO: rebase + using var hc = new HttpClient(); + var resp = await hc.PostAsJsonAsync(new Uri(wellKnown.Api.GetApiBaseUrl(), "auth/login"), request); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + return (await resp.Content.ReadFromJsonAsync())!; + } + + public async Task RegisterAsync(RegisterRequest request) { + // TODO: rebase + using var hc = new HttpClient(); + var resp = await hc.PostAsJsonAsync(new Uri(wellKnown.Api.GetApiBaseUrl(), "auth/register"), request); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + return (await resp.Content.ReadFromJsonAsync())!; + } +} + +public class AuthenticatedSpacebarClient { + private readonly ILogger _logger; + + public AuthenticatedSpacebarClient(ILogger logger, IServiceProvider sp, SpacebarClientWellKnown wellKnown, string token) { + _logger = logger; + ApiHttpClient = new HttpClient() { + BaseAddress = wellKnown.Api.GetApiBaseUrl() + }; + ApiHttpClient.DefaultRequestHeaders.Authorization = new("Bearer", token); + Gateway = new(sp.GetRequiredService>(), wellKnown, token); + ClientWellKnown = wellKnown; + } + + public HttpClient ApiHttpClient { get; set; } + public AuthenticatedSpacebarGatewayClient Gateway { get; set; } + public SpacebarClientWellKnown ClientWellKnown { get; set; } + + // TODO: write a proper full user model... + public async Task GetCurrentUser() { + var resp = await ApiHttpClient.GetAsync("users/@me"); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + return (await resp.Content.ReadFromJsonAsync())!; + } + + ~AuthenticatedSpacebarClient() { + ApiHttpClient.Dispose(); + } + + public SpacebarClientChannel GetChannel(long channelId) => new(this, channelId); + public SpacebarClientGuild GetGuild(long guildId) => new(this, guildId); + + public async Task CreateGuild(CreateGuildRequest req) { + var resp = await ApiHttpClient.PostAsJsonAsync("guilds", req, new JsonSerializerOptions() { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + return (await resp.Content.ReadFromJsonAsync())!; + } +} + +public class SpacebarClientChannel(AuthenticatedSpacebarClient client, long channelId) { + public long Id => channelId; + + public async Task> GetMessagesAsync(long? around = null, long? before = null, long? after = null, int limit = 50) { + var uri = $"channels/{channelId}/messages?limit={limit}"; + if (around.HasValue) uri += $"&around={around.Value}"; + if (before.HasValue) uri += $"&before={before.Value}"; + if (after.HasValue) uri += $"&after={after.Value}"; + + var resp = await client.ApiHttpClient.GetAsync(uri); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + var data = await resp.Content.ReadFromJsonAsync>(); + Console.WriteLine(data.ToJson(indent: false, ignoreNull: true)); + return data.Select(x => x.Deserialize()).ToList(); + } +} + +public class SpacebarClientGuild(AuthenticatedSpacebarClient client, long guildId) { + public long Id => guildId; + + public async Task> GetChannelsAsync() { + var uri = $"guilds/{guildId}/channels"; + + var resp = await client.ApiHttpClient.GetAsync(uri); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + var data = await resp.Content.ReadFromJsonAsync>(); + Console.WriteLine(data.ToJson(indent: false, ignoreNull: true)); + return data.Select(x => x.Deserialize()).ToList(); + } + + public async Task CreateChannelAsync(CreateChannelRequest req) { + var resp = await client.ApiHttpClient.PostAsJsonAsync($"guilds/{guildId}/channels", req, new JsonSerializerOptions() { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + return (await resp.Content.ReadFromJsonAsync())!; + } +} + +public class AuthenticatedSpacebarGatewayClient(ILogger logger, SpacebarClientWellKnown wellKnown, string token) { + public ClientWebSocket RawClientWebSocket = new(); + public int Sequence; + public bool TraceGatewayMessages = false; + + public IdentifyRequest IdentifyData { get; } = new() { + Intents = (GatewayIntentFlags?)0xFFFFFFFF, // too lazy to do math, just gimme everything + Capabilities = 0 + }; + + public List> OnGatewayMessage { get; } = []; + public List>> OnceGatewayMessage { get; } = []; + + public async Task Connect() { + if (RawClientWebSocket.State is WebSocketState.Connecting or WebSocketState.Open) return; + Sequence = 0; + await RawClientWebSocket.ConnectAsync(new Uri(wellKnown.Gateway.BaseUrl).AddQuery("encoding", "json"), CancellationToken.None); + } + + public async Task Start() { + await foreach (var msg in _runReceiveLoop()) { + // logger.LogInformation("Got gateway message: {msg}", msg); + if (msg.Opcode == GatewayOpcode.S2CHello) { + _ = _runHeartbeatLoop(msg.GetData()!.HeartbeatInterval).ContinueWith(ct => { + logger.LogWarning("Heartbeat loop exited!"); + if (ct.IsFaulted) throw ct.Exception; + }); + IdentifyData.Token = token; + await RawClientWebSocket.SendAsync(JsonSerializer.SerializeToUtf8Bytes(new GatewayPayload() { + Opcode = GatewayOpcode.C2SIdentify, // Identify + EventData = IdentifyData.ToJsonNode().AsObject() + }), WebSocketMessageType.Text, WebSocketMessageFlags.EndOfMessage, CancellationToken.None); + } + else if (msg.Opcode == GatewayOpcode.S2CHeartbeatAck) { + logger.LogInformation("Got heartbeat ACK from server!"); + } + + await Task.WhenAll(OnGatewayMessage.Select(async x => { + try { + await x(msg); + } + catch (Exception e) { + logger.LogError("OnGatewayMessage callback failed: {e}", e); + } + }).ToArray()); + foreach (var t in OnceGatewayMessage.Select((Func> Callback, Task WasHandled) (cb) => (cb, cb(msg))).ToList()) { + try { + var handled = await t.WasHandled; + if (handled) { + OnceGatewayMessage.Remove(t.Callback); + } + } + catch (Exception e) { + logger.LogError("OnceGatewayMessage callback failed: {e}", e); + } + } + } + } + + private async Task _runHeartbeatLoop(int interval) { + while (RawClientWebSocket.State < WebSocketState.Closed) { + await RawClientWebSocket.SendAsync(JsonSerializer.SerializeToUtf8Bytes(new GatewayPayload() { + Opcode = GatewayOpcode.C2SQoSHeartbeat, // QoS Heartbeat + EventData = new QoSHeartbeatRequest() { + Sequence = Sequence, + QoSPayload = new() { + Active = true, + Reasons = ["foregrounded"], + Version = 27 + } + }.ToJsonNode().AsObject() + }), WebSocketMessageType.Text, WebSocketMessageFlags.EndOfMessage, CancellationToken.None); + await Task.Delay(interval); + } + } + + private const int ReceiveBufferSize = 2 * 1024 * 1024; + + private async IAsyncEnumerable _runReceiveLoop() { + List messageParts = []; + List<(string Name, TimeSpan Elapsed)> trace = []; + var buffer = new byte[ReceiveBufferSize]; + int idx = 0; + + while (RawClientWebSocket.State < WebSocketState.Closed) { + var sw = Stopwatch.StartNew(); + + var msg = await RawClientWebSocket.ReceiveAsync(buffer, CancellationToken.None); + trace.Add(($"RCV.{idx}", sw.GetElapsedAndRestart())); + + Console.WriteLine($"Websocket message chunk read: {msg.MessageType} {msg.Count} {msg.EndOfMessage}"); + messageParts.AddRange(msg.Count < ReceiveBufferSize ? buffer[..msg.Count] : buffer); + trace.Add(($"STO.{idx}({msg.Count})", sw.GetElapsedAndRestart())); + idx++; + + if (msg.EndOfMessage) { + Console.WriteLine($"Got message, deserialising {messageParts.Count} bytes..."); + var fullMsg = messageParts.ToArray(); + trace.Add(($"LD({messageParts.Count})", sw.GetElapsedAndRestart())); + + var d = JsonSerializer.Deserialize(fullMsg); + trace.Add(($"LJS({fullMsg.Length})", sw.GetElapsedAndRestart())); + + Console.WriteLine($"Received gateway message #{d.Sequence}: {(byte)d.Opcode}/{d.Opcode.ToString().Replace("S2C", "")} {d.DispatchEventType}"); + yield return d ?? throw new InvalidDataException("Gateway message deserialisation returned null?"); + trace.Add(($"YLD", sw.GetElapsedAndRestart())); + + if (TraceGatewayMessages) { + Console.WriteLine("Yielded message!"); + Console.WriteLine("Trace:"); + foreach (var t in trace) + Console.WriteLine($" - {t.Name}: {t.Elapsed}"); + } + + messageParts.Clear(); + trace.Clear(); + trace.Add(($"RST", sw.GetElapsedAndRestart())); + } + } + } + + ~AuthenticatedSpacebarGatewayClient() { + RawClientWebSocket.Dispose(); + } +} \ No newline at end of file diff --git a/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClientProviderService.cs b/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClientProviderService.cs new file mode 100644 index 0000000..5f6ab09 --- /dev/null +++ b/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClientProviderService.cs @@ -0,0 +1,28 @@ +using ArcaneLibs.Collections; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Spacebar.Sdk.Core; + +public class SpacebarClientProviderService(ILogger logger, IServiceProvider serviceProvider, SpacebarClientWellKnownResolverService clientWellKnownResolver) { + private static readonly SemaphoreCache UnauthenticatedClientCache = new(); + private static readonly SemaphoreCache AuthenticatedClientCache = new(); + + public async Task GetUnauthenticatedClientAsync(string serverName) { + return await UnauthenticatedClientCache.GetOrAdd(serverName, async () => { + logger.LogInformation("Creating a new unauthenticated client for {serverName}!", serverName); + var clientLogger = serviceProvider.GetRequiredService>(); + var wellKnown = await clientWellKnownResolver.ResolveClientWellKnown(serverName); + return new UnauthenticatedSpacebarClient(clientLogger, wellKnown); + }); + } + + public async Task GetAuthenticatedClientAsync(string serverName, string accessToken) { + return await AuthenticatedClientCache.GetOrAdd(serverName, async () => { + logger.LogInformation("Creating a new authenticated client for {serverName}!", serverName); + var clientLogger = serviceProvider.GetRequiredService>(); + var wellKnown = await clientWellKnownResolver.ResolveClientWellKnown(serverName); + return new AuthenticatedSpacebarClient(clientLogger, serviceProvider, wellKnown, accessToken); + }); + } +} \ No newline at end of file diff --git a/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClientWellKnownResolver.cs b/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClientWellKnownResolver.cs new file mode 100644 index 0000000..1530f92 --- /dev/null +++ b/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClientWellKnownResolver.cs @@ -0,0 +1,65 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using ArcaneLibs.Extensions; +using Microsoft.Extensions.Logging; + +namespace Spacebar.Sdk.Core; + +public class SpacebarClientWellKnownResolverService(ILogger logger) { + private static string _getBaseUrl(string input) { + if (input.StartsWithAnyOf("https://", "http://")) return input; + return "https://" + input; + } + + public async Task ResolveClientWellKnown(string serverName) { + using var hc = new HttpClient(); + var filtered = _getBaseUrl(serverName); + logger.LogInformation("Resolving .well-known for {serverName} ({filtered})", serverName, filtered); + return await hc.GetFromJsonAsync($"{filtered}/.well-known/spacebar/client")!; + } +} + +public class SpacebarClientWellKnown { + [JsonPropertyName("api")] + public required ApiWellKnownData Api { get; set; } + + [JsonPropertyName("cdn")] + public required GenericUrlWellKnownData Cdn { get; set; } + + [JsonPropertyName("admin")] + public GenericUrlWellKnownData? Admin { get; set; } + + [JsonPropertyName("gateway")] + public required GatewayWellKnownData Gateway { get; set; } + + public class GenericUrlWellKnownData { + [JsonPropertyName("baseUrl")] + public required string BaseUrl { get; set; } + } + + public class ApiWellKnownData : GenericUrlWellKnownData { + [JsonPropertyName("apiVersions")] + public required ApiVersionsData ApiVersions { get; set; } + + // Utility methods + public Uri GetApiBaseUrl(string? version = null) { + return new Uri(BaseUrl + "/api/v" + (version ?? ApiVersions.Default)); + } + + public class ApiVersionsData { + [JsonPropertyName("default")] + public required string Default { get; set; } + + [JsonPropertyName("active")] + public required List Active { get; set; } + } + } + + public class GatewayWellKnownData : GenericUrlWellKnownData { + [JsonPropertyName("encoding")] + public required List Encoding { get; set; } + + [JsonPropertyName("compression")] + public required List Compression { get; set; } + } +} \ No newline at end of file diff --git a/extra/admin-api/Utilities/Spacebar.Sdk/Spacebar.Sdk.csproj b/extra/admin-api/Utilities/Spacebar.Sdk/Spacebar.Sdk.csproj new file mode 100644 index 0000000..859f42e --- /dev/null +++ b/extra/admin-api/Utilities/Spacebar.Sdk/Spacebar.Sdk.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + +