diff --git a/extra/admin-api/Interop/Spacebar.Interop.Replication.Abstractions/ISpacebarReplication.cs b/extra/admin-api/Interop/Spacebar.Interop.Replication.Abstractions/ISpacebarReplication.cs index 9398eedfd..c568c98dc 100644 --- a/extra/admin-api/Interop/Spacebar.Interop.Replication.Abstractions/ISpacebarReplication.cs +++ b/extra/admin-api/Interop/Spacebar.Interop.Replication.Abstractions/ISpacebarReplication.cs @@ -2,5 +2,6 @@ public interface ISpacebarReplication { public Task InitializeAsync(); - public Task SendAsync(ReplicationMessage message); + public Task SendAsync(ContentlessReplicationMessage message); + public Task SendAsync(ReplicationMessage message); } diff --git a/extra/admin-api/Interop/Spacebar.Interop.Replication.Abstractions/ReplicationMessage.cs b/extra/admin-api/Interop/Spacebar.Interop.Replication.Abstractions/ReplicationMessage.cs index bc7dc41f4..630ef47e4 100644 --- a/extra/admin-api/Interop/Spacebar.Interop.Replication.Abstractions/ReplicationMessage.cs +++ b/extra/admin-api/Interop/Spacebar.Interop.Replication.Abstractions/ReplicationMessage.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; namespace Spacebar.Interop.Replication.Abstractions; -public class ReplicationMessage { +public class ContentlessReplicationMessage { [JsonPropertyName("channel_id")] public string? ChannelId { get; set; } @@ -11,7 +11,7 @@ public class ReplicationMessage { [JsonPropertyName("user_id")] public string? UserId { get; set; } - + [JsonPropertyName("session_id")] public string? SessionId { get; set; } @@ -24,9 +24,11 @@ public class ReplicationMessage { [JsonPropertyName("origin")] public string? Origin { get; set; } - [JsonPropertyName("data")] - public object Payload { get; set; } = null!; - [JsonPropertyName("reconnect_delay")] public int? ReconnectDelay { get; set; } +} + +public class ReplicationMessage : ContentlessReplicationMessage { + [JsonPropertyName("data")] + public TPayload Payload { get; set; } = default!; } \ No newline at end of file diff --git a/extra/admin-api/Interop/Spacebar.Interop.Replication.RabbitMq/RabbitMqSpacebarReplication.cs b/extra/admin-api/Interop/Spacebar.Interop.Replication.RabbitMq/RabbitMqSpacebarReplication.cs index f979d923a..b818b13ea 100644 --- a/extra/admin-api/Interop/Spacebar.Interop.Replication.RabbitMq/RabbitMqSpacebarReplication.cs +++ b/extra/admin-api/Interop/Spacebar.Interop.Replication.RabbitMq/RabbitMqSpacebarReplication.cs @@ -22,16 +22,17 @@ public class RabbitMqSpacebarReplication : ISpacebarReplication { _mqChannel = await _mqConnection.CreateChannelAsync(); } - public async Task SendAsync(ReplicationMessage message) { + // HACK: body is required in rabbitmq... + private async Task SendAsyncInternal(ContentlessReplicationMessage message, object? body = null) { var exchangeId = message.GuildId ?? message.ChannelId ?? message.UserId ?? "global"; await _mqChannel.ExchangeDeclareAsync(exchange: exchangeId, type: ExchangeType.Fanout, durable: false); var props = new BasicProperties() { Type = message.Event }; var publishSuccess = false; - var body = message.Payload.ToJson().AsBytes().ToArray(); // TODO: byte array payloads etc someday? + var encodedBody = body.ToJson().AsBytes().ToArray(); // TODO: byte array payloads etc someday? do { try { - await _mqChannel.BasicPublishAsync(exchange: exchangeId, routingKey: "", mandatory: true, basicProperties: props, body: body); + await _mqChannel.BasicPublishAsync(exchange: exchangeId, routingKey: "", mandatory: true, basicProperties: props, body: encodedBody); publishSuccess = true; } catch (Exception e) { @@ -40,4 +41,7 @@ public class RabbitMqSpacebarReplication : ISpacebarReplication { } } while (!publishSuccess); } + + public async Task SendAsync(ContentlessReplicationMessage message) => await SendAsyncInternal(message); + public async Task SendAsync(ReplicationMessage message) => await SendAsyncInternal(message, message.Payload); } \ No newline at end of file diff --git a/extra/admin-api/Interop/Spacebar.Interop.Replication.UnixSocket/UnixSocketSpacebarReplication.cs b/extra/admin-api/Interop/Spacebar.Interop.Replication.UnixSocket/UnixSocketSpacebarReplication.cs index 2959c71b9..1c23c1c47 100644 --- a/extra/admin-api/Interop/Spacebar.Interop.Replication.UnixSocket/UnixSocketSpacebarReplication.cs +++ b/extra/admin-api/Interop/Spacebar.Interop.Replication.UnixSocket/UnixSocketSpacebarReplication.cs @@ -20,7 +20,7 @@ public class UnixSocketSpacebarReplication(UnixSocketConfiguration conf) : ISpac }; } - public async Task SendAsync(ReplicationMessage message) { + public async Task SendAsync(ContentlessReplicationMessage message) { // message format: [uint32be length][payload] var payload = JsonSerializer.SerializeToUtf8Bytes(message); byte[] formattedPayload = [..BitConverter.GetBytes(System.Net.IPAddress.HostToNetworkOrder(payload.Length)), ..payload]; @@ -30,6 +30,8 @@ public class UnixSocketSpacebarReplication(UnixSocketConfiguration conf) : ISpac skv.Value.SendAsync(formattedPayload); }); } + + async Task ISpacebarReplication.SendAsync(ReplicationMessage message) => await SendAsync(message); } public class UnixSocketConfiguration { diff --git a/extra/admin-api/Models/Spacebar.Models.Gateway/BulkMessageDeleteResponse.cs b/extra/admin-api/Models/Spacebar.Models.Gateway/BulkMessageDeleteResponse.cs new file mode 100644 index 000000000..f4cb669a5 --- /dev/null +++ b/extra/admin-api/Models/Spacebar.Models.Gateway/BulkMessageDeleteResponse.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Spacebar.Models.Gateway; + +public class BulkMessageDeleteResponse { + [JsonPropertyName("guild_id")] + public string? GuildId { get; set; } + + [JsonPropertyName("channel_id")] + public required string ChannelId { get; set; } + + [JsonPropertyName("ids")] + public required List MessageIds { get; set; } +} \ No newline at end of file diff --git a/extra/admin-api/Models/Spacebar.Models.Gateway/ChannelStatusesRequest.cs b/extra/admin-api/Models/Spacebar.Models.Gateway/ChannelStatusesRequest.cs new file mode 100644 index 000000000..94737ffe4 --- /dev/null +++ b/extra/admin-api/Models/Spacebar.Models.Gateway/ChannelStatusesRequest.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Spacebar.Models.Gateway; + +public class ChannelStatusesRequest { + [JsonPropertyName("guild_id")] + [JsonRequired] + public JsonValue GuildIdRawValue { get; set; } = null!; + + [JsonIgnore] + public string? GuildId { + get => GuildIdRawValue.GetValueKind() == JsonValueKind.String ? GuildIdRawValue.GetValue() : null; + [MemberNotNull] set => GuildIdRawValue = JsonValue.Create(value!); + } + + [JsonIgnore] + public List? GuildIds { + get => GuildIdRawValue.GetValueKind() == JsonValueKind.Array ? GuildIdRawValue.AsArray().Deserialize>() : null; + [MemberNotNull] set => GuildIdRawValue = JsonValue.Create(value!)!; + } +} + +public class ChannelInfoRequest : ChannelStatusesRequest { + [JsonPropertyName("fields")] + public required List Fields { get; set; } +} + +public class ChannelStatus { + [JsonPropertyName("id")] + public string ChannelId { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } +} + +public class ChannelStatusesResponse { + [JsonPropertyName("guild_id")] + public string GuildId { get; set; } + + [JsonPropertyName("channels")] + public List Channels { get; set; } +} + +public class ChannelInfo { + [JsonPropertyName("id")] + public required string ChannelId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("voice_start_time")] + public DateTimeOffset? VoiceStartTime { get; set; } +} + +public class ChannelInfoResponse { + [JsonPropertyName("guild_id")] + public string GuildId { get; set; } + + [JsonPropertyName("channels")] + public List Channels { get; set; } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/ChannelController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/ChannelController.cs index 45a9e4370..cccc47609 100644 --- a/extra/admin-api/Spacebar.AdminApi/Controllers/ChannelController.cs +++ b/extra/admin-api/Spacebar.AdminApi/Controllers/ChannelController.cs @@ -6,6 +6,7 @@ using Spacebar.Models.AdminApi; using Spacebar.Interop.Authentication.AspNetCore; using Spacebar.Models.Db.Contexts; using Spacebar.Models.Db.Models; +using Spacebar.Models.Gateway; namespace Spacebar.AdminApi.Controllers; @@ -18,15 +19,15 @@ public class ChannelController( SpacebarAspNetAuthenticationService auth, ISpacebarReplication replication ) : ControllerBase { - [HttpDelete("{id}")] public async Task DeleteById(string id) { (await auth.GetCurrentUserAsync(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR); - replication.SendAsync(new() { + // TODO: proper type + await replication.SendAsync(new() { Origin = "AdminApi/DeleteChannelById", ChannelId = id, Event = "CHANNEL_DELETE", - Payload = await db.Channels.SingleAsync (x=>x.Id == id) + Payload = await db.Channels.SingleAsync(x => x.Id == id) }); await db.Channels.Where(x => x.Id == id).ExecuteDeleteAsync(); @@ -58,13 +59,13 @@ public class ChannelController( break; } - await replication.SendAsync(new() { + await replication.SendAsync(new() { ChannelId = channelId, Event = "MESSAGE_BULK_DELETE", - Payload = new { - ids = messageIds, - channel_id = channelId, - guild_id = guildId, + Payload = new BulkMessageDeleteResponse() { + GuildId = guildId, + ChannelId = channelId, + MessageIds = messageIds, }, Origin = "Admin API (GuildController.DeleteUser)", }); diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/GuildController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/GuildController.cs index 62df5a38c..38d8b3c2c 100644 --- a/extra/admin-api/Spacebar.AdminApi/Controllers/GuildController.cs +++ b/extra/admin-api/Spacebar.AdminApi/Controllers/GuildController.cs @@ -6,6 +6,7 @@ using Spacebar.Models.AdminApi; using Spacebar.Interop.Authentication.AspNetCore; using Spacebar.Models.Db.Contexts; using Spacebar.Models.Db.Models; +using Spacebar.Models.Gateway; namespace Spacebar.AdminApi.Controllers; @@ -218,13 +219,13 @@ public class GuildController( break; } - await replication.SendAsync(new() { + await replication.SendAsync(new() { ChannelId = channelId, Event = "MESSAGE_BULK_DELETE", - Payload = new { - ids = messageIds, - channel_id = channelId, - guild_id = guildId, + Payload = new() { + GuildId = guildId, + ChannelId = channelId, + MessageIds = messageIds, }, Origin = "Admin API (GuildController.DeleteUser)", }); diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/TestControllers/IpcTestController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/TestControllers/IpcTestController.cs index 228606f79..6881e1e34 100644 --- a/extra/admin-api/Spacebar.AdminApi/Controllers/TestControllers/IpcTestController.cs +++ b/extra/admin-api/Spacebar.AdminApi/Controllers/TestControllers/IpcTestController.cs @@ -40,7 +40,8 @@ public class IpcTestController( while (true) { var clr = re.Next(); color = clr.r << 16 | clr.g << 8 | clr.b; - await replication.SendAsync(new() { + // TODO: create type + await replication.SendAsync(new() { Event = "GUILD_ROLE_UPDATE", GuildId = guildId, Origin = "Admin API (GET /users/test)", diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/UserController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/UserController.cs index 8eacb0c3f..f1f861d54 100644 --- a/extra/admin-api/Spacebar.AdminApi/Controllers/UserController.cs +++ b/extra/admin-api/Spacebar.AdminApi/Controllers/UserController.cs @@ -6,6 +6,7 @@ using Spacebar.Interop.Authentication.AspNetCore; using Spacebar.Interop.Replication.Abstractions; using Spacebar.Models.AdminApi; using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Gateway; namespace Spacebar.AdminApi.Controllers; @@ -218,13 +219,13 @@ public class UserController( break; } - await replication.SendAsync(new() { + await replication.SendAsync(new() { Event = "MESSAGE_BULK_DELETE", ChannelId = channelId, - Payload = new { - channel_id = channelId, - guild_id = guildId, - ids = messageIds, + Payload = new() { + GuildId = guildId, + ChannelId = channelId, + MessageIds = messageIds, }, Origin = "AdminApi/DeleteMessagesForChannel" }); diff --git a/extra/admin-api/Spacebar.AdminApi/Spacebar.AdminApi.csproj b/extra/admin-api/Spacebar.AdminApi/Spacebar.AdminApi.csproj index c1b5f6337..9adf4d0d5 100644 --- a/extra/admin-api/Spacebar.AdminApi/Spacebar.AdminApi.csproj +++ b/extra/admin-api/Spacebar.AdminApi/Spacebar.AdminApi.csproj @@ -24,6 +24,8 @@ + + diff --git a/extra/admin-api/Spacebar.GatewayOffload/Controllers/ChannelStatusController.cs b/extra/admin-api/Spacebar.GatewayOffload/Controllers/ChannelStatusController.cs new file mode 100644 index 000000000..767e838be --- /dev/null +++ b/extra/admin-api/Spacebar.GatewayOffload/Controllers/ChannelStatusController.cs @@ -0,0 +1,68 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Spacebar.Interop.Authentication.AspNetCore; +using Spacebar.Interop.Replication.Abstractions; +using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Gateway; + +namespace Spacebar.GatewayOffload.Controllers; + +[ApiController] +[Route("/_spacebar/offload/gateway")] +public class ChannelStatusController(ILogger logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) + : ControllerBase { + [HttpPost("channelStatuses")] + public async IAsyncEnumerable> GetChannelStatuses([FromBody] ChannelStatusesRequest req) { + await foreach (var res in GetChannelInfos(new() { + Fields = ["status"], + GuildIdRawValue = req.GuildIdRawValue, + })) { + yield return new() { + Payload = new() { + GuildId = res.Payload.GuildId, + Channels = res.Payload.Channels.Select(c => new ChannelStatus { + ChannelId = c.ChannelId, + Status = c.Status!, + }).ToList(), + } + }; + } + } + + [HttpPost("channelInfo")] + public async IAsyncEnumerable> GetChannelInfos([FromBody] ChannelInfoRequest req) { + var user = await authService.GetCurrentUserAsync(Request); + string[] statusOptions = [ + "Vibing ✨", + "Hanging out: 12%...", + "Communicating...", + // idk, i cant come up with more stuff, maybe suggestions welcome, or actually storing some data? + ]; + + foreach (var guildId in req.GuildIds ?? [req.GuildId!]) { + var channels = (await db.Channels.Include(x => x.VoiceStates).Where(x => x.Type == 2 && x.GuildId == guildId && x.VoiceStates.Count > 0) + .Select(x => x.Id) + .ToListAsync()) + .Select(x => new { + id = x, + status = statusOptions[new Random().Next(statusOptions.Length)], // TODO: We don't currently store channel statuses, so make some stuff up + voiceStartTime = DateTime.Now.Subtract(TimeSpan.FromMinutes(new Random().Next(1, 120))), // TODO: We also don't store voice start times, so make some stuff up + }).ToList(); + + yield return new() { + Payload = new() { + GuildId = guildId, + Channels = channels.Select(c => new ChannelInfo { + ChannelId = c.id, + Status = req.Fields.Contains("status") ? c.status : null, + VoiceStartTime = req.Fields.Contains("voice_start_time") ? c.voiceStartTime : null, + }).ToList(), + }, + }; + } + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.GatewayOffload/Controllers/IdentifyController.cs b/extra/admin-api/Spacebar.GatewayOffload/Controllers/IdentifyController.cs index d49790a98..02bad394c 100644 --- a/extra/admin-api/Spacebar.GatewayOffload/Controllers/IdentifyController.cs +++ b/extra/admin-api/Spacebar.GatewayOffload/Controllers/IdentifyController.cs @@ -11,7 +11,7 @@ namespace Spacebar.GatewayOffload.Controllers; [Route("/_spacebar/offload/gateway/Identify")] public class IdentifyController(ILogger logger, SpacebarAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase { [HttpPost("")] - public async IAsyncEnumerable DoIdentify(IdentifyRequest payload) { + public async IAsyncEnumerable DoIdentify(IdentifyRequest payload) { var user = await TraceResult.TraceAsync("getAuthUser", () => authService.GetCurrentUserAsync(payload.Token)); var session = await TraceResult.TraceAsync("getAuthSession", () => authService.GetCurrentSessionAsync(payload.Token)); @@ -37,12 +37,13 @@ public class IdentifyController(ILogger logger, SpacebarAuth } } - yield return new() { - Payload = new ReadyResponse { }, + yield return new ReplicationMessage() { + Payload = new() { }, }; } - private ReplicationMessage Close(CloseCode closeCode) => new() { + // TODO: type? also, implement this in gateway lol + private ReplicationMessage Close(CloseCode closeCode) => new() { Origin = "IdentifyController", Event = "SB_GW_CLOSE", Payload = new { diff --git a/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op12Controller.cs b/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op12Controller.cs index db45a8952..884a9de85 100644 --- a/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op12Controller.cs +++ b/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op12Controller.cs @@ -20,7 +20,7 @@ namespace Spacebar.GatewayOffload.Controllers; public class Op12Controller(ILogger logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase { [HttpPost("")] - public async IAsyncEnumerable DoGuildSync(List guildIds) + public async IAsyncEnumerable> DoGuildSync(List guildIds) { var user = await authService.GetCurrentUserAsync(Request); guildIds = (await db.Members.AsNoTracking().Where(x => x.Id == user.Id).Select(x => x.GuildId).ToListAsync()) diff --git a/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op14Controller.cs b/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op14Controller.cs index 9f32eef2f..57f1390ce 100644 --- a/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op14Controller.cs +++ b/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op14Controller.cs @@ -14,7 +14,8 @@ namespace Spacebar.GatewayOffload.Controllers; [Route("/_spacebar/offload/gateway/LazyRequest")] public class Op14Controller(ILogger logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase { [HttpPost] - public async IAsyncEnumerable DoLazyRequest([FromBody] LazyRequest payload) { + // TODO: actually return something? + public async IAsyncEnumerable DoLazyRequest([FromBody] LazyRequest payload) { var user = await TraceResult.TraceAsync("getAuthUser", () => authService.GetCurrentUserAsync(Request)); var session = await TraceResult.TraceAsync("getAuthSession", () => authService.GetCurrentSessionAsync(Request)); diff --git a/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op8Controller.cs b/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op8Controller.cs new file mode 100644 index 000000000..dbcd4452f --- /dev/null +++ b/extra/admin-api/Spacebar.GatewayOffload/Controllers/Op8Controller.cs @@ -0,0 +1,93 @@ +using System.Collections.Frozen; +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; +using ArcaneLibs.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Spacebar.DataMappings.Generic; +using Spacebar.Interop.Authentication.AspNetCore; +using Spacebar.Interop.Replication.Abstractions; +using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Db.Models; +using Spacebar.Models.Gateway; +using Spacebar.Models.Generic; + +namespace Spacebar.GatewayOffload.Controllers; + +[ApiController] +[Route("/_spacebar/offload/gateway/GuildSync")] +public class Op8Controller(ILogger logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase +{ + [HttpPost("")] + public async IAsyncEnumerable> DoGuildSync(List guildIds) + { + var user = await authService.GetCurrentUserAsync(Request); + guildIds = (await db.Members.AsNoTracking().Where(x => x.Id == user.Id).Select(x => x.GuildId).ToListAsync()) + .Intersect(guildIds) + .OrderByDescending(gi => db.Members.Count(m => m.GuildId == gi)) + .ToList(); + + var syncs = guildIds.Select(GetGuildSyncAsync).ToList().ToAsyncResultEnumerable(); + await foreach (var res in syncs) + { + yield return new() + { + Origin = "OFFLOAD_GUILD_SYNC", + UserId = user.Id, + Event = "GUILD_SYNC", + CreatedAt = DateTime.Now, + Payload = res + }; + } + } + + // TODO: figure out how to abstract this to a function without EFCore complaining about not being translatable... + private static Expression> IsOnline = (Session session) => session.Status != "offline" && session.Status != "invisible" && session.Status != "unknown"; + + private async Task GetGuildSyncAsync(string guildId) + { + await using var sc = sp.CreateAsyncScope(); + var _db = sc.ServiceProvider.GetRequiredService(); + var memberCount = await _db.Members.AsNoTracking().Where(x => x.GuildId == guildId).CountAsync(); + + var offlineTreshold = DateTime.Now.Subtract(TimeSpan.FromDays(14)); + var isLargeGuild = memberCount > 10000; + + var members = await _db.Members.AsNoTracking().Where(x => x.GuildId == guildId) + .Include(x => x.IdNavigation) + .ThenInclude(x => x.Sessions.Where(s => + !s.IsAdminSession && ( + // see TODO on IsOnline - somehow need to replicate `IsOnline(s)` + s.Status != "offline" && s.Status != "invisible" && s.Status != "unknown" + ) && (!isLargeGuild || s.LastSeen >= offlineTreshold))) + .Where(x => x.IdNavigation.Sessions.Count > 0) // ignore members without sessions + .ToListAsync(); + + var mappedPartialUsers = members.Select(x => x.IdNavigation).ToFrozenDictionary(x => x.Id, x => x.ToPartialUser()); + var mappedMembers = members.ToFrozenDictionary(m => m.Id, m => m.ToPublicMember(mappedPartialUsers[m.Id])); + + var presences = members.Select(x => x.IdNavigation).Where(x => x.Sessions.Count > 0).ToFrozenDictionary(x => x.Id, x => + { + var sortedSessions = x.Sessions.OrderByDescending(s => s.LastSeen).ToList(); + return new Presence() + { + GuildId = guildId, + User = mappedPartialUsers[x.Id], + Activities = x.Sessions.Where(s => s.Status is not ("offline" or "invisible" or "unknown")) + .SelectMany(s => JsonSerializer.Deserialize(s.Activities) ?? []).ToList(), + Status = sortedSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Status))?.Status ?? "offline", + ClientStatus = JsonSerializer.Deserialize(sortedSessions.First(s => !string.IsNullOrWhiteSpace(s.ClientStatus)).ClientStatus) ?? + new() + }; + }).Where(x => x.Value.Activities.Count > 0).ToFrozenDictionary(); + + var r = new GuildSyncResponse() + { + GuildId = guildId, + Members = mappedMembers.Values.ToList(), + Presences = presences.Values.ToList() + }; + return r; + } +} \ No newline at end of file