diff --git a/extra/admin-api/Spacebar.Offload/Controllers/ChannelStatusController.cs b/extra/admin-api/Spacebar.Offload/Controllers/ChannelStatusController.cs
new file mode 100644
index 00000000..0843218e
--- /dev/null
+++ b/extra/admin-api/Spacebar.Offload/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<ChannelStatusController> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp)
+ : ControllerBase {
+ [HttpPost("ChannelStatuses")]
+ public async IAsyncEnumerable<ReplicationMessage<ChannelStatusesResponse>> 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<ReplicationMessage<ChannelInfoResponse>> 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.Offload/Controllers/IdentifyController.cs b/extra/admin-api/Spacebar.Offload/Controllers/IdentifyController.cs
new file mode 100644
index 00000000..02bad394
--- /dev/null
+++ b/extra/admin-api/Spacebar.Offload/Controllers/IdentifyController.cs
@@ -0,0 +1,53 @@
+using Microsoft.AspNetCore.Mvc;
+using Spacebar.Interop.Authentication;
+using Spacebar.Interop.Replication.Abstractions;
+using Spacebar.Models.Db.Contexts;
+using Spacebar.Models.Gateway;
+using Spacebar.Models.Generic;
+
+namespace Spacebar.GatewayOffload.Controllers;
+
+[ApiController]
+[Route("/_spacebar/offload/gateway/Identify")]
+public class IdentifyController(ILogger<IdentifyController> logger, SpacebarAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase {
+ [HttpPost("")]
+ public async IAsyncEnumerable<ContentlessReplicationMessage> DoIdentify(IdentifyRequest payload) {
+ var user = await TraceResult.TraceAsync("getAuthUser", () => authService.GetCurrentUserAsync(payload.Token));
+ var session = await TraceResult.TraceAsync("getAuthSession", () => authService.GetCurrentSessionAsync(payload.Token));
+
+ var socketMeta = new SbWebsocketMeta() {
+ // Auth data
+ AccessToken = payload.Token,
+ UserId = user.Result.Id,
+ SessionId = session.Result.SessionId,
+ // Client capabilities
+ Capabilities = payload.Capabilities ??= 0,
+ LargeThreshold = payload.LargeTreshold ??= user.Result.Bot ? 20 : 250,
+ Intents = payload.Intents ??= (GatewayIntentFlags)0b_110_11111111_11111111_11111111_11111111,
+ // Sharding info
+ ShardId = payload.Shard?[0],
+ ShardCount = payload.Shard?[1],
+ };
+
+ if (socketMeta is { ShardId: not null, ShardCount: not null }) {
+ if (socketMeta.ShardId < 0 || socketMeta.ShardCount <= 0 || socketMeta.ShardId >= socketMeta.ShardCount) {
+ logger.LogWarning("Invalid sharding from {userId}: {shardId}/{shardCount}", user.Result.Id, socketMeta.ShardId, socketMeta.ShardCount);
+ yield return this.Close(CloseCode.InvalidShard);
+ yield break;
+ }
+ }
+
+ yield return new ReplicationMessage<ReadyResponse>() {
+ Payload = new() { },
+ };
+ }
+
+ // TODO: type? also, implement this in gateway lol
+ private ReplicationMessage<object?> Close(CloseCode closeCode) => new() {
+ Origin = "IdentifyController",
+ Event = "SB_GW_CLOSE",
+ Payload = new {
+ code = closeCode,
+ }
+ };
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Offload/Controllers/Op12Controller.cs b/extra/admin-api/Spacebar.Offload/Controllers/Op12Controller.cs
new file mode 100644
index 00000000..884a9de8
--- /dev/null
+++ b/extra/admin-api/Spacebar.Offload/Controllers/Op12Controller.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 Op12Controller(ILogger<Op12Controller> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase
+{
+ [HttpPost("")]
+ public async IAsyncEnumerable<ReplicationMessage<GuildSyncResponse>> DoGuildSync(List<string> 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<Func<Session, bool>> IsOnline = (Session session) => session.Status != "offline" && session.Status != "invisible" && session.Status != "unknown";
+
+ private async Task<GuildSyncResponse> GetGuildSyncAsync(string guildId)
+ {
+ await using var sc = sp.CreateAsyncScope();
+ var _db = sc.ServiceProvider.GetRequiredService<SpacebarDbContext>();
+ 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<JsonObject[]>(s.Activities) ?? []).ToList(),
+ Status = sortedSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Status))?.Status ?? "offline",
+ ClientStatus = JsonSerializer.Deserialize<Presence.ClientStatuses>(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
diff --git a/extra/admin-api/Spacebar.Offload/Controllers/Op14Controller.cs b/extra/admin-api/Spacebar.Offload/Controllers/Op14Controller.cs
new file mode 100644
index 00000000..57f1390c
--- /dev/null
+++ b/extra/admin-api/Spacebar.Offload/Controllers/Op14Controller.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Spacebar.GatewayOffload.Extensions.Db;
+using Spacebar.Interop.Authentication.AspNetCore;
+using Spacebar.Interop.Replication.Abstractions;
+using Spacebar.Models.Db.Contexts;
+using Spacebar.Models.Gateway;
+using Spacebar.Models.Generic;
+
+namespace Spacebar.GatewayOffload.Controllers;
+
+[ApiController]
+[Route("/_spacebar/offload/gateway/LazyRequest")]
+public class Op14Controller(ILogger<Op12Controller> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase {
+ [HttpPost]
+ // TODO: actually return something?
+ public async IAsyncEnumerable<ContentlessReplicationMessage> DoLazyRequest([FromBody] LazyRequest payload) {
+ var user = await TraceResult.TraceAsync("getAuthUser", () => authService.GetCurrentUserAsync(Request));
+ var session = await TraceResult.TraceAsync("getAuthSession", () => authService.GetCurrentSessionAsync(Request));
+
+ if (!await db.Members.AsNoTracking().AnyAsync(m => m.GuildId == payload.GuildId && m.Id == user.Result.Id)) {
+ logger.LogWarning("User {user} requested lazy member list for guild {guildId}, but is not a member", user.Result.Id, payload.GuildId);
+ yield break;
+ }
+
+ if (payload.Channels.Count == 0) {
+ logger.LogWarning("User {user} requested lazy member list for guild {guildId}, but is not a member", user.Result.Tag, payload.GuildId);
+ yield break;
+ }
+
+ // Fetch hoisted roles for the guild to define groups
+ var hoistedRoles = await db.Roles
+ .AsNoTracking()
+ .Where(r => r.GuildId == payload.GuildId && r.Hoist)
+ .OrderByDescending(r => r.Position)
+ .Select(r => new { r.Id })
+ .ToListAsync();
+ }
+
+ private async Task<string?> GetMemberListIdAsync(SpacebarDbContext db, string guildId, string channelId) {
+ var channel = await db.Channels.AsNoTracking().FirstOrDefaultAsync(c => c.Id == channelId && c.GuildId == guildId);
+ if (channel == null) return null;
+
+ if (string.IsNullOrWhiteSpace(channel.PermissionOverwrites) || channel.PermissionOverwrites == "[]") {
+ return "everyone";
+ }
+
+ return null; // TODO
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Spacebar.Offload/Controllers/Op8Controller.cs b/extra/admin-api/Spacebar.Offload/Controllers/Op8Controller.cs
new file mode 100644
index 00000000..ac68bb22
--- /dev/null
+++ b/extra/admin-api/Spacebar.Offload/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/GuildMembers")]
+public class Op8Controller(ILogger<Op8Controller> logger, SpacebarAspNetAuthenticationService authService, SpacebarDbContext db, IServiceProvider sp) : ControllerBase
+{
+ [HttpPost("")]
+ public async IAsyncEnumerable<ReplicationMessage<GuildSyncResponse>> DoGuildSync(List<string> 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<Func<Session, bool>> IsOnline = (Session session) => session.Status != "offline" && session.Status != "invisible" && session.Status != "unknown";
+
+ private async Task<GuildSyncResponse> GetGuildSyncAsync(string guildId)
+ {
+ await using var sc = sp.CreateAsyncScope();
+ var _db = sc.ServiceProvider.GetRequiredService<SpacebarDbContext>();
+ 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<JsonObject[]>(s.Activities) ?? []).ToList(),
+ Status = sortedSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Status))?.Status ?? "offline",
+ ClientStatus = JsonSerializer.Deserialize<Presence.ClientStatuses>(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
|