about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-08-23 02:55:07 +0200
committerRory& <root@rory.gay>2024-08-23 02:55:07 +0200
commitf50ed7ccc4347907d3c5ec6b68e1b84c4e0a7a0e (patch)
treed77d1d1f30e0ea01051561d8caaadeed2fdcf439
parentMinor cleanup (diff)
downloadLibMatrix-f50ed7ccc4347907d3c5ec6b68e1b84c4e0a7a0e.tar.xz
Synapse admin API stuff, a mass of other changes
-rw-r--r--LibMatrix/Extensions/MatrixHttpClient.Single.cs5
-rw-r--r--LibMatrix/Helpers/SyncHelper.cs9
-rw-r--r--LibMatrix/Helpers/SyncStateResolver.cs100
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs31
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs (renamed from LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs)2
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs28
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs56
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs169
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs31
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs (renamed from LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListResult.cs)6
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs11
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminEventReportListResult.cs58
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs250
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs (renamed from LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminUserListResult.cs)21
-rw-r--r--LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs195
-rw-r--r--LibMatrix/Interfaces/Services/IStorageProvider.cs16
-rw-r--r--LibMatrix/Responses/SyncResponse.cs15
-rw-r--r--LibMatrix/RoomTypes/GenericRoom.cs37
-rw-r--r--LibMatrix/StateEvent.cs65
19 files changed, 951 insertions, 154 deletions
diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
index a5e94e5..3c8aea4 100644
--- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs
+++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
@@ -242,5 +242,10 @@ public class MatrixHttpClient {
         };
         return await SendAsync(request, cancellationToken);
     }
+
+    public async Task DeleteAsync(string url) {
+        var request = new HttpRequestMessage(HttpMethod.Delete, url);
+        await SendAsync(request);
+    }
 }
 #endif
\ No newline at end of file
diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs
index 07c3bb0..7d5364b 100644
--- a/LibMatrix/Helpers/SyncHelper.cs
+++ b/LibMatrix/Helpers/SyncHelper.cs
@@ -52,6 +52,12 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
 
     public TimeSpan MinimumDelay { get; set; } = new(0);
 
+    public async Task<int> GetUnoptimisedStoreCount() {
+        if (storageProvider is null) return -1;
+        var keys = await storageProvider.GetAllKeysAsync();
+        return keys.Count(x => !x.StartsWith("old/")) - 1;
+    }
+
     private async Task UpdateFilterAsync() {
         if (!string.IsNullOrWhiteSpace(NamedFilterName)) {
             _filterId = await homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(NamedFilterName);
@@ -101,7 +107,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
         if (!string.IsNullOrWhiteSpace(Since)) url += $"&since={Since}";
         if (_filterId is not null) url += $"&filter={_filterId}";
 
-        logger?.LogInformation("SyncHelper: Calling: {}", url);
+        // logger?.LogInformation("SyncHelper: Calling: {}", url);
 
         try {
             var httpResp = await homeserver.ClientHttpClient.GetAsync(url, cancellationToken ?? CancellationToken.None);
@@ -111,6 +117,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
             var resp = await httpResp.Content.ReadFromJsonAsync(cancellationToken: cancellationToken ?? CancellationToken.None,
                 jsonTypeInfo: SyncResponseSerializerContext.Default.SyncResponse);
             logger?.LogInformation("Deserialized sync response: {} bytes, {} elapsed, {} total", httpResp.GetContentLength(), deserializeSw.Elapsed, sw.Elapsed);
+
             var timeToWait = MinimumDelay.Subtract(sw.Elapsed);
             if (timeToWait.TotalMilliseconds > 0)
                 await Task.Delay(timeToWait);
diff --git a/LibMatrix/Helpers/SyncStateResolver.cs b/LibMatrix/Helpers/SyncStateResolver.cs
index e2dbdee..e9c5938 100644
--- a/LibMatrix/Helpers/SyncStateResolver.cs
+++ b/LibMatrix/Helpers/SyncStateResolver.cs
@@ -1,3 +1,5 @@
+using System.Collections.Frozen;
+using System.Diagnostics;
 using ArcaneLibs.Extensions;
 using LibMatrix.Extensions;
 using LibMatrix.Filters;
@@ -26,16 +28,10 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
         _syncHelper.SetPresence = SetPresence;
         _syncHelper.Filter = Filter;
         _syncHelper.FullState = FullState;
-        // run sync or grab from storage if available
-        // var sync = storageProvider != null && await storageProvider.ObjectExistsAsync(Since ?? "init")
-        //     ? await storageProvider.LoadObjectAsync<SyncResponse>(Since ?? "init")
-        //     : await _syncHelper.SyncAsync(cancellationToken);
+
         var sync = await _syncHelper.SyncAsync(cancellationToken);
         if (sync is null) return await ContinueAsync(cancellationToken);
 
-        // if (storageProvider != null && !await storageProvider.ObjectExistsAsync(Since ?? "init"))
-            // await storageProvider.SaveObjectAsync(Since ?? "init", sync);
-
         if (MergedState is null) MergedState = sync;
         else MergedState = MergeSyncs(MergedState, sync);
         Since = sync.NextBatch;
@@ -45,22 +41,98 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
 
     public async Task OptimiseStore() {
         if (storageProvider is null) return;
+        if (!await storageProvider.ObjectExistsAsync("init")) return;
+
+        Console.Write("Optimising sync store...");
+        var initLoadTask = storageProvider.LoadObjectAsync<SyncResponse>("init");
+        var keys = (await storageProvider.GetAllKeysAsync()).ToFrozenSet();
+        var count = keys.Count(x => !x.StartsWith("old/")) - 1;
+        Console.WriteLine($"Found {count} entries to optimise.");
 
-        var keys = await storageProvider.GetAllKeysAsync();
-        var count = keys.Count - 2;
-        var merged = await storageProvider.LoadObjectAsync<SyncResponse>("init");
+        var merged = await initLoadTask;
         if (merged is null) return;
+        if (!keys.Contains(merged.NextBatch)) {
+            Console.WriteLine("Next response after initial sync is not present, not checkpointing!");
+            return;
+        }
+
+        // We back up old entries
+        var oldPath = $"old/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
+        await storageProvider.MoveObjectAsync("init", $"{oldPath}/init");
+
+        var moveTasks = new List<Task>();
 
         while (keys.Contains(merged.NextBatch)) {
+            Console.Write($"Merging {merged.NextBatch}, {--count} remaining... ");
+            var sw = Stopwatch.StartNew();
+            var swt = Stopwatch.StartNew();
             var next = await storageProvider.LoadObjectAsync<SyncResponse>(merged.NextBatch);
-            if (next is null) break;
+            Console.Write($"Load {sw.GetElapsedAndRestart().TotalMilliseconds}ms... ");
+            if (next is null || merged.NextBatch == next.NextBatch) break;
+
+            Console.Write($"Check {sw.GetElapsedAndRestart().TotalMilliseconds}ms... ");
+            // back up old entry
+            moveTasks.Add(storageProvider.MoveObjectAsync(merged.NextBatch, $"{oldPath}/{merged.NextBatch}"));
+            Console.Write($"Move {sw.GetElapsedAndRestart().TotalMilliseconds}ms... ");
+
             merged = MergeSyncs(merged, next);
-            Console.WriteLine($"Merged {merged.NextBatch}, {--count} remaining...");
+            Console.Write($"Merge {sw.GetElapsedAndRestart().TotalMilliseconds}ms... ");
+            Console.WriteLine($"Total {swt.Elapsed.TotalMilliseconds}ms");
+            // Console.WriteLine($"Merged {merged.NextBatch}, {--count} remaining...");
+        }
+
+        await storageProvider.SaveObjectAsync("init", merged);
+        await Task.WhenAll(moveTasks);
+    }
+
+    public async Task UnrollOptimisedStore() {
+        if (storageProvider is null) return;
+        Console.WriteLine("WARNING: Unrolling sync store!");
+    }
+
+    public async Task SquashOptimisedStore(int targetCountPerCheckpoint) {
+        Console.Write($"Balancing optimised store to {targetCountPerCheckpoint} per checkpoint...");
+        var checkpoints = await GetCheckpointMap();
+        if (checkpoints is null) return;
+
+        Console.WriteLine(
+            $" Stats: {checkpoints.Count} checkpoints with [{checkpoints.Min(x => x.Value.Count)} < ~{checkpoints.Average(x => x.Value.Count)} < {checkpoints.Max(x => x.Value.Count)}] entries");
+        Console.WriteLine($"Found {checkpoints?.Count ?? 0} checkpoints.");
+    }
+
+    public async Task dev() {
+        var keys = (await storageProvider?.GetAllKeysAsync()).ToFrozenSet();
+        var times = new Dictionary<long, List<string>>();
+        var values = keys.Select(async x => Task.Run(async () => (x, await storageProvider?.LoadObjectAsync<SyncResponse>(x)))).ToAsyncEnumerable();
+        await foreach (var task in values) {
+            var (key, data) = await task;
+            if (data is null) continue;
+            var derivTime = data.GetDerivedSyncTime();
+            if (!times.ContainsKey(derivTime)) times[derivTime] = new();
+            times[derivTime].Add(key);
         }
 
-        await storageProvider.SaveObjectAsync("merged", merged);
+        foreach (var (time, ckeys) in times.OrderBy(x => x.Key)) {
+            Console.WriteLine($"{time}: {ckeys.Count} keys");
+        }
+    }
+
+    private async Task<Dictionary<ulong, List<string>>?> GetCheckpointMap() {
+        if (storageProvider is null) return null;
+        var keys = (await storageProvider.GetAllKeysAsync()).ToFrozenSet();
+        var map = new Dictionary<ulong, List<string>>();
+        foreach (var key in keys) {
+            if (!key.StartsWith("old/")) continue;
+            var parts = key.Split('/');
+            if (parts.Length < 3) continue;
+            // if (!map.ContainsKey(parts[1])) map[parts[1]] = new();
+            // map[parts[1]].Add(parts[2]);
+            if (!ulong.TryParse(parts[1], out var checkpoint)) continue;
+            if (!map.ContainsKey(checkpoint)) map[checkpoint] = new();
+            map[checkpoint].Add(parts[2]);
+        }
 
-        Environment.Exit(0);
+        return map.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value);
     }
 
     private SyncResponse MergeSyncs(SyncResponse oldSync, SyncResponse newSync) {
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs
new file mode 100644
index 0000000..197fd5d
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRegistrationTokenUpdateRequest {
+    [JsonPropertyName("uses_allowed")]
+    public int? UsesAllowed { get; set; }
+
+    [JsonPropertyName("expiry_time")]
+    public long? ExpiryTime { get; set; }
+
+    [JsonIgnore]
+    public DateTime? ExpiresAt {
+        get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime : null;
+        set => ExpiryTime = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+    }
+
+    [JsonIgnore]
+    public TimeSpan? ExpiresAfter {
+        get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime - DateTimeOffset.Now : null;
+        set => ExpiryTime = value.HasValue ? (DateTimeOffset.Now + value.Value).ToUnixTimeMilliseconds() : null;
+    }
+}
+
+public class SynapseAdminRegistrationTokenCreateRequest : SynapseAdminRegistrationTokenUpdateRequest {
+    [JsonPropertyName("token")]
+    public string? Token { get; set; }
+
+    [JsonPropertyName("length")]
+    public int? Length { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs
index f4c927a..67a3104 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
 
 namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
 
-public class AdminRoomDeleteRequest {
+public class SynapseAdminRoomDeleteRequest {
     [JsonPropertyName("new_room_user_id")]
     public string? NewRoomUserId { get; set; }
 
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs
new file mode 100644
index 0000000..2394b98
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs
@@ -0,0 +1,28 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminBackgroundUpdateStatusResponse {
+    [JsonPropertyName("enabled")]
+    public bool Enabled { get; set; }
+
+    [JsonPropertyName("current_updates")]
+    public Dictionary<string, BackgroundUpdateInfo> CurrentUpdates { get; set; }
+
+    public class BackgroundUpdateInfo {
+        [JsonPropertyName("name")]
+        public string Name { get; set; }
+
+        [JsonPropertyName("total_item_count")]
+        public int TotalItemCount { get; set; }
+
+        [JsonPropertyName("total_duration_ms")]
+        public double TotalDurationMs { get; set; }
+
+        [JsonPropertyName("average_items_per_ms")]
+        public double AverageItemsPerMs { get; set; }
+
+        [JsonIgnore]
+        public TimeSpan TotalDuration => TimeSpan.FromMilliseconds(TotalDurationMs);
+    }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs
new file mode 100644
index 0000000..646a4b5
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs
@@ -0,0 +1,56 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminDestinationListResult : SynapseNextTokenTotalCollectionResult {
+    [JsonPropertyName("destinations")]
+    public List<SynapseAdminDestinationListResultDestination> Destinations { get; set; } = new();
+
+    public class SynapseAdminDestinationListResultDestination {
+        [JsonPropertyName("destination")]
+        public string Destination { get; set; }
+
+        [JsonPropertyName("retry_last_ts")]
+        public long RetryLastTs { get; set; }
+
+        [JsonPropertyName("retry_interval")]
+        public long RetryInterval { get; set; }
+
+        [JsonPropertyName("failure_ts")]
+        public long? FailureTs { get; set; }
+
+        [JsonPropertyName("last_successful_stream_ordering")]
+        public long? LastSuccessfulStreamOrdering { get; set; }
+
+        [JsonIgnore]
+        public DateTime? FailureTsDateTime {
+            get => FailureTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(FailureTs.Value).DateTime : null;
+            set => FailureTs = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+        }
+
+        [JsonIgnore]
+        public DateTime? RetryLastTsDateTime {
+            get => DateTimeOffset.FromUnixTimeMilliseconds(RetryLastTs).DateTime;
+            set => RetryLastTs = new DateTimeOffset(value.Value).ToUnixTimeMilliseconds();
+        }
+
+        [JsonIgnore]
+        public TimeSpan RetryIntervalTimeSpan {
+            get => TimeSpan.FromMilliseconds(RetryInterval);
+            set => RetryInterval = (long)value.TotalMilliseconds;
+        }
+    }
+}
+
+public class SynapseAdminDestinationRoomListResult : SynapseNextTokenTotalCollectionResult {
+    [JsonPropertyName("rooms")]
+    public List<SynapseAdminDestinationRoomListResultRoom> Rooms { get; set; } = new();
+
+    public class SynapseAdminDestinationRoomListResultRoom {
+        [JsonPropertyName("room_id")]
+        public string RoomId { get; set; }
+
+        [JsonPropertyName("stream_ordering")]
+        public int StreamOrdering { get; set; }
+    }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
new file mode 100644
index 0000000..10fc039
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
@@ -0,0 +1,169 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs;
+using ArcaneLibs.Attributes;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes;
+using LibMatrix.Extensions;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminEventReportListResult : SynapseNextTokenTotalCollectionResult {
+    [JsonPropertyName("event_reports")]
+    public List<SynapseAdminEventReportListResultReport> Reports { get; set; } = new();
+
+    public class SynapseAdminEventReportListResultReport {
+        [JsonPropertyName("event_id")]
+        public string EventId { get; set; }
+
+        [JsonPropertyName("id")]
+        public string Id { get; set; }
+
+        [JsonPropertyName("reason")]
+        public string? Reason { get; set; }
+
+        [JsonPropertyName("score")]
+        public int? Score { get; set; }
+
+        [JsonPropertyName("received_ts")]
+        public long ReceivedTs { get; set; }
+
+        [JsonPropertyName("canonical_alias")]
+        public string? CanonicalAlias { get; set; }
+
+        [JsonPropertyName("room_id")]
+        public string RoomId { get; set; }
+
+        [JsonPropertyName("name")]
+        public string? Name { get; set; }
+
+        [JsonPropertyName("sender")]
+        public string Sender { get; set; }
+
+        [JsonPropertyName("user_id")]
+        public string UserId { get; set; }
+
+        [JsonIgnore]
+        public DateTime ReceivedTsDateTime {
+            get => DateTimeOffset.FromUnixTimeMilliseconds(ReceivedTs).DateTime;
+            set => ReceivedTs = new DateTimeOffset(value).ToUnixTimeMilliseconds();
+        }
+    }
+
+    public class SynapseAdminEventReportListResultReportWithDetails : SynapseAdminEventReportListResultReport {
+        [JsonPropertyName("event_json")]
+        public SynapseEventJson EventJson { get; set; }
+
+        public class SynapseEventJson {
+            [JsonPropertyName("auth_events")]
+            public List<string> AuthEvents { get; set; }
+
+            [JsonPropertyName("content")]
+            public JsonObject? RawContent { get; set; }
+
+            [JsonPropertyName("depth")]
+            public int Depth { get; set; }
+
+            [JsonPropertyName("hashes")]
+            public Dictionary<string, string> Hashes { get; set; }
+
+            [JsonPropertyName("origin")]
+            public string Origin { get; set; }
+
+            [JsonPropertyName("origin_server_ts")]
+            public long OriginServerTs { get; set; }
+
+            [JsonPropertyName("prev_events")]
+            public List<string> PrevEvents { get; set; }
+
+            [JsonPropertyName("prev_state")]
+            public List<object> PrevState { get; set; }
+
+            [JsonPropertyName("room_id")]
+            public string RoomId { get; set; }
+
+            [JsonPropertyName("sender")]
+            public string Sender { get; set; }
+
+            [JsonPropertyName("signatures")]
+            public Dictionary<string, Dictionary<string, string>> Signatures { get; set; }
+
+            [JsonPropertyName("type")]
+            public string Type { get; set; }
+
+            [JsonPropertyName("unsigned")]
+            public JsonObject? Unsigned { get; set; }
+
+            // Extra... copied from StateEventResponse
+
+            [JsonIgnore]
+            public Type MappedType => StateEvent.GetStateEventType(Type);
+
+            [JsonIgnore]
+            public bool IsLegacyType => MappedType.GetCustomAttributes<MatrixEventAttribute>().FirstOrDefault(x => x.EventName == Type)?.Legacy ?? false;
+
+            [JsonIgnore]
+            public string FriendlyTypeName => MappedType.GetFriendlyNameOrNull() ?? Type;
+
+            [JsonIgnore]
+            public string FriendlyTypeNamePlural => MappedType.GetFriendlyNamePluralOrNull() ?? Type;
+
+            private static readonly JsonSerializerOptions TypedContentSerializerOptions = new() {
+                Converters = {
+                    new JsonFloatStringConverter(),
+                    new JsonDoubleStringConverter(),
+                    new JsonDecimalStringConverter()
+                }
+            };
+
+            [JsonIgnore]
+            [SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")]
+            public EventContent? TypedContent {
+                get {
+                    ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies();
+                    // if (Type == "m.receipt") {
+                    // return null;
+                    // }
+                    try {
+                        var mappedType = StateEvent.GetStateEventType(Type);
+                        if (mappedType == typeof(UnknownEventContent))
+                            Console.WriteLine($"Warning: unknown event type '{Type}'");
+                        var deserialisedContent = (EventContent)RawContent.Deserialize(mappedType, TypedContentSerializerOptions)!;
+                        return deserialisedContent;
+                    }
+                    catch (JsonException e) {
+                        Console.WriteLine(e);
+                        Console.WriteLine("Content:\n" + (RawContent?.ToJson() ?? "null"));
+                    }
+
+                    return null;
+                }
+                set {
+                    if (value is null)
+                        RawContent?.Clear();
+                    else
+                        RawContent = JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(value, value.GetType(),
+                            new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }));
+                }
+            }
+
+            //debug
+            [JsonIgnore]
+            public string InternalSelfTypeName {
+                get {
+                    var res = GetType().Name switch {
+                        "StateEvent`1" => "StateEvent",
+                        _ => GetType().Name
+                    };
+                    return res;
+                }
+            }
+
+            [JsonIgnore]
+            public string InternalContentTypeName => TypedContent?.GetType().Name ?? "null";
+        }
+    }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs
new file mode 100644
index 0000000..fa92ef9
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRegistrationTokenListResult {
+    [JsonPropertyName("registration_tokens")]
+    public List<SynapseAdminRegistrationTokenListResultToken> RegistrationTokens { get; set; } = new();
+
+    public class SynapseAdminRegistrationTokenListResultToken {
+        [JsonPropertyName("token")]
+        public string Token { get; set; }
+
+        [JsonPropertyName("uses_allowed")]
+        public int? UsesAllowed { get; set; }
+
+        [JsonPropertyName("pending")]
+        public int Pending { get; set; }
+
+        [JsonPropertyName("completed")]
+        public int Completed { get; set; }
+
+        [JsonPropertyName("expiry_time")]
+        public long? ExpiryTime { get; set; }
+
+        [JsonIgnore]
+        public DateTime? ExpiryTimeDateTime {
+            get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime : null;
+            set => ExpiryTime = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+        }
+    }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
index c9d7e52..d84c89b 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListResult.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
 
 namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
 
-public class AdminRoomListResult {
+public class SynapseAdminRoomListResult {
     [JsonPropertyName("offset")]
     public int Offset { get; set; }
 
@@ -16,9 +16,9 @@ public class AdminRoomListResult {
     public int? PrevBatch { get; set; }
 
     [JsonPropertyName("rooms")]
-    public List<AdminRoomListResultRoom> Rooms { get; set; } = new();
+    public List<SynapseAdminRoomListResultRoom> Rooms { get; set; } = new();
 
-    public class AdminRoomListResultRoom {
+    public class SynapseAdminRoomListResultRoom {
         [JsonPropertyName("room_id")]
         public required string RoomId { get; set; }
 
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs
new file mode 100644
index 0000000..97e85ad
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRoomMediaListResult {
+    [JsonPropertyName("local")]
+    public List<string> Local { get; set; } = new();
+
+    [JsonPropertyName("remote")]
+    public List<string> Remote { get; set; } = new();
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminEventReportListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminEventReportListResult.cs
deleted file mode 100644
index 030108a..0000000
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminEventReportListResult.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
-
-public class SynapseAdminEventReportListResult {
-    [JsonPropertyName("offset")]
-    public int Offset { get; set; }
-
-    [JsonPropertyName("total")]
-    public int Total { get; set; }
-
-    [JsonPropertyName("next_token")]
-    public string? NextToken { get; set; }
-
-    [JsonPropertyName("event_reports")]
-    public List<SynapseAdminEventReportListResultReport> Reports { get; set; } = new();
-
-    public class SynapseAdminEventReportListResultReport {
-        [JsonPropertyName("name")]
-        public string Name { get; set; }
-
-        [JsonPropertyName("is_guest")]
-        public bool? IsGuest { get; set; }
-
-        [JsonPropertyName("admin")]
-        public bool? Admin { get; set; }
-
-        [JsonPropertyName("user_type")]
-        public string? UserType { get; set; }
-
-        [JsonPropertyName("deactivated")]
-        public bool Deactivated { get; set; }
-
-        [JsonPropertyName("erased")]
-        public bool Erased { get; set; }
-
-        [JsonPropertyName("shadow_banned")]
-        public bool ShadowBanned { get; set; }
-
-        [JsonPropertyName("displayname")]
-        public string? DisplayName { get; set; }
-
-        [JsonPropertyName("avatar_url")]
-        public string? AvatarUrl { get; set; }
-
-        [JsonPropertyName("creation_ts")]
-        public long CreationTs { get; set; }
-
-        [JsonPropertyName("last_seen_ts")]
-        public long? LastSeenTs { get; set; }
-
-        [JsonPropertyName("locked")]
-        public bool Locked { get; set; }
-
-        [JsonPropertyName("approved")]
-        public bool Approved { get; set; }
-    }
-}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs
new file mode 100644
index 0000000..36a5596
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs
@@ -0,0 +1,250 @@
+using System.Buffers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseNextTokenTotalCollectionResult {
+    [JsonPropertyName("total")]
+    public int Total { get; set; }
+
+    [JsonPropertyName("next_token")]
+    public string? NextToken { get; set; }
+}
+
+// [JsonConverter(typeof(SynapseCollectionJsonConverter<>))]
+public class SynapseCollectionResult<T>(string chunkKey = "chunk", string prevTokenKey = "prev_token", string nextTokenKey = "next_token", string totalKey = "total") {
+    public int? Total { get; set; }
+    public string? PrevToken { get; set; }
+    public string? NextToken { get; set; }
+    public List<T> Chunk { get; set; } = [];
+
+    // TODO: figure out how to provide an IAsyncEnumerable<T> for this
+    // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/use-utf8jsonreader#read-from-a-stream-using-utf8jsonreader
+
+    // public async IAsyncEnumerable<T> FromJsonAsync(Stream stream) {
+    //
+    // }
+
+    public SynapseCollectionResult<T> FromJson(Stream stream, Action<T> action) {
+        byte[] buffer = new byte[4096];
+        _ = stream.Read(buffer);
+        var reader = new Utf8JsonReader(buffer, isFinalBlock: false, state: default);
+
+        try {
+            FromJsonInternal(stream, ref buffer, ref reader, action);
+        }
+        catch (JsonException e) {
+            Console.WriteLine($"Caught a JsonException: {e}");
+            int hexdumpWidth = 64;
+            Console.WriteLine($"Check hexdump line {reader.BytesConsumed / hexdumpWidth} index {reader.BytesConsumed % hexdumpWidth}");
+            buffer.HexDump(64);
+        }
+        finally { }
+
+        return this;
+    }
+
+    private void FromJsonInternal(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader, Action<T> action) {
+        while (!reader.IsFinalBlock) {
+            while (!reader.Read()) {
+                GetMoreBytesFromStream(stream, ref buffer, ref reader);
+            }
+
+            if (reader.TokenType == JsonTokenType.PropertyName) {
+                var propName = reader.GetString();
+                Console.WriteLine($"SynapseCollectionResult: encountered property name: {propName}");
+
+                while (!reader.Read()) {
+                    GetMoreBytesFromStream(stream, ref buffer, ref reader);
+                }
+
+                Console.WriteLine($"{reader.BytesConsumed}/{stream.Position} {reader.TokenType}");
+
+                if (propName == totalKey && reader.TokenType == JsonTokenType.Number) {
+                    Total = reader.GetInt32();
+                }
+                else if (propName == prevTokenKey && reader.TokenType == JsonTokenType.String) {
+                    PrevToken = reader.GetString();
+                }
+                else if (propName == nextTokenKey && reader.TokenType == JsonTokenType.String) {
+                    NextToken = reader.GetString();
+                }
+                else if (propName == chunkKey) {
+                    if (reader.TokenType == JsonTokenType.StartArray) {
+                        while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) {
+                            // if (reader.TokenType == JsonTokenType.EndArray) {
+                            // break;
+                            // }
+                            // Console.WriteLine($"Encountered token in chunk: {reader.TokenType}");
+                            // var _buf = reader.ValueSequence.ToArray();
+                            // try {
+                            //     var item = JsonSerializer.Deserialize<T>(_buf);
+                            //     action(item);
+                            //     Chunk.Add(item);
+                            // }
+                            // catch(JsonException e) {
+                            //     Console.WriteLine($"Caught a JsonException: {e}");
+                            //     int hexdumpWidth = 64;
+                            //
+                            //     // Console.WriteLine($"Check hexdump line {reader.BytesConsumed / hexdumpWidth} index {reader.BytesConsumed % hexdumpWidth}");
+                            //     Console.WriteLine($"Buffer length: {_buf.Length}");
+                            //     _buf.HexDump(64);
+                            //     throw;
+                            // }
+                            var item = ReadItem(stream, ref buffer, ref reader);
+                            action(item);
+                            Chunk.Add(item);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private T ReadItem(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+        while (!reader.Read()) {
+            GetMoreBytesFromStream(stream, ref buffer, ref reader);
+        }
+
+        // handle nullable types
+        if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) {
+            if (reader.TokenType == JsonTokenType.Null) {
+                return default(T);
+            }
+        }
+
+        // if(typeof(T) == typeof(string)) {
+        //     return (T)(object)reader.GetString();
+        // }
+        // else if(typeof(T) == typeof(int)) {
+        //     return (T)(object)reader.GetInt32();
+        // }
+        // else {
+        //     var _buf = reader.ValueSequence.ToArray();
+        //     return JsonSerializer.Deserialize<T>(_buf);
+        // }
+
+        // default branch uses "object?" cast to avoid compiler error
+        // add more branches here as nessesary
+        // reader.Read();
+        var call = typeof(T) switch {
+            Type t when t == typeof(string) => reader.GetString(),
+            _ => ReadObject<T>(stream, ref buffer, ref reader)
+        };
+
+        object ReadObject<T>(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+            if (reader.TokenType != JsonTokenType.PropertyName) {
+                throw new JsonException();
+            }
+
+            List<byte> objBuffer = [(byte)'{', ..reader.ValueSequence.ToArray()];
+            var currentDepth = reader.CurrentDepth;
+            while (reader.CurrentDepth >= currentDepth) {
+                while (!reader.Read()) {
+                    GetMoreBytesFromStream(stream, ref buffer, ref reader);
+                }
+
+                if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == currentDepth) {
+                    break;
+                }
+
+                objBuffer.AddRange(reader.ValueSpan);
+            }
+
+            return JsonSerializer.Deserialize<T>(objBuffer.ToArray());
+        }
+
+        return (T)call;
+
+        // return JsonSerializer.Deserialize<T>(ref reader);
+    }
+
+    private static void GetMoreBytesFromStream(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+        int bytesRead;
+        if (reader.BytesConsumed < buffer.Length) {
+            ReadOnlySpan<byte> leftover = buffer.AsSpan((int)reader.BytesConsumed);
+
+            if (leftover.Length == buffer.Length) {
+                Array.Resize(ref buffer, buffer.Length * 2);
+                Console.WriteLine($"Increased buffer size to {buffer.Length}");
+            }
+
+            leftover.CopyTo(buffer);
+            bytesRead = stream.Read(buffer.AsSpan(leftover.Length));
+        }
+        else {
+            bytesRead = stream.Read(buffer);
+        }
+
+        // Console.WriteLine($"String in buffer is: {Encoding.UTF8.GetString(buffer)}");
+        reader = new Utf8JsonReader(buffer, isFinalBlock: bytesRead == 0, reader.CurrentState);
+    }
+}
+
+public partial class SynapseCollectionJsonConverter<T> : JsonConverter<SynapseCollectionResult<T>> {
+    public override SynapseCollectionResult<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
+        if (reader.TokenType != JsonTokenType.StartObject) {
+            throw new JsonException();
+        }
+
+        var result = new SynapseCollectionResult<T>();
+        while (reader.Read()) {
+            if (reader.TokenType == JsonTokenType.EndObject) {
+                break;
+            }
+
+            if (reader.TokenType != JsonTokenType.PropertyName) {
+                throw new JsonException();
+            }
+
+            var propName = reader.GetString();
+            reader.Read();
+            if (propName == "total") {
+                result.Total = reader.GetInt32();
+            }
+            else if (propName == "prev_token") {
+                result.PrevToken = reader.GetString();
+            }
+            else if (propName == "next_token") {
+                result.NextToken = reader.GetString();
+            }
+            else if (propName == "chunk") {
+                if (reader.TokenType != JsonTokenType.StartArray) {
+                    throw new JsonException();
+                }
+
+                while (reader.Read()) {
+                    if (reader.TokenType == JsonTokenType.EndArray) {
+                        break;
+                    }
+
+                    var item = JsonSerializer.Deserialize<T>(ref reader, options);
+                    result.Chunk.Add(item);
+                }
+            }
+        }
+
+        return result;
+    }
+
+    public override void Write(Utf8JsonWriter writer, SynapseCollectionResult<T> value, JsonSerializerOptions options) {
+        writer.WriteStartObject();
+        if (value.Total is not null)
+            writer.WriteNumber("total", value.Total ?? 0);
+        if (value.PrevToken is not null)
+            writer.WriteString("prev_token", value.PrevToken);
+        if (value.NextToken is not null)
+            writer.WriteString("next_token", value.NextToken);
+
+        writer.WriteStartArray("chunk");
+        foreach (var item in value.Chunk) {
+            JsonSerializer.Serialize(writer, item, options);
+        }
+
+        writer.WriteEndArray();
+        writer.WriteEndObject();
+    }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminUserListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs
index 9b0c481..3132906 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminUserListResult.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
 
 namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
 
-public class AdminUserListResult {
+public class SynapseAdminUserListResult {
     [JsonPropertyName("offset")]
     public int Offset { get; set; }
 
@@ -13,9 +13,9 @@ public class AdminUserListResult {
     public string? NextToken { get; set; }
 
     [JsonPropertyName("users")]
-    public List<AdminUserListResultUser> Users { get; set; } = new();
+    public List<SynapseAdminUserListResultUser> Users { get; set; } = new();
 
-    public class AdminUserListResultUser {
+    public class SynapseAdminUserListResultUser {
         [JsonPropertyName("name")]
         public string Name { get; set; }
 
@@ -52,7 +52,20 @@ public class AdminUserListResult {
         [JsonPropertyName("locked")]
         public bool Locked { get; set; }
 
+        // Requires enabling MSC3866
         [JsonPropertyName("approved")]
-        public bool Approved { get; set; }
+        public bool? Approved { get; set; }
+
+        [JsonIgnore]
+        public DateTime CreationTsDateTime {
+            get => DateTimeOffset.FromUnixTimeMilliseconds(CreationTs).DateTime;
+            set => CreationTs = new DateTimeOffset(value).ToUnixTimeMilliseconds();
+        }
+
+        [JsonIgnore]
+        public DateTime? LastSeenTsDateTime {
+            get => LastSeenTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(LastSeenTs.Value).DateTime : null;
+            set => LastSeenTs = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+        }
     }
 }
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
index b3902eb..4d8a577 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
@@ -1,5 +1,7 @@
 using System.Net.Http.Json;
+using System.Text.Json;
 using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
 using ArcaneLibs.Extensions;
 using LibMatrix.Filters;
 using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
@@ -10,12 +12,13 @@ namespace LibMatrix.Homeservers.ImplementationDetails.Synapse;
 
 public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedHomeserver) {
     // https://github.com/element-hq/synapse/tree/develop/docs/admin_api
+    // https://github.com/element-hq/synapse/tree/develop/docs/usage/administration/admin_api
 
 #region Rooms
 
-    public async IAsyncEnumerable<AdminRoomListResult.AdminRoomListResultRoom> SearchRoomsAsync(int limit = int.MaxValue, int chunkLimit = 250, string orderBy = "name",
-        string dir = "f", string? searchTerm = null, SynapseAdminLocalRoomQueryFilter? localFilter = null) {
-        AdminRoomListResult? res = null;
+    public async IAsyncEnumerable<SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom> SearchRoomsAsync(int limit = int.MaxValue, int chunkLimit = 250,
+        string orderBy = "name", string dir = "f", string? searchTerm = null, SynapseAdminLocalRoomQueryFilter? localFilter = null) {
+        SynapseAdminRoomListResult? res = null;
         var i = 0;
         int? totalRooms = null;
         do {
@@ -26,7 +29,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
 
             Console.WriteLine($"--- ADMIN Querying Room List with URL: {url} - Already have {i} items... ---");
 
-            res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<AdminRoomListResult>(url);
+            res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomListResult>(url);
             totalRooms ??= res.TotalRooms;
             Console.WriteLine(res.ToJson(false));
             foreach (var room in res.Rooms) {
@@ -117,7 +120,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
 
 #region Users
 
-    public async IAsyncEnumerable<AdminUserListResult.AdminUserListResultUser> SearchUsersAsync(int limit = int.MaxValue, int chunkLimit = 250,
+    public async IAsyncEnumerable<SynapseAdminUserListResult.SynapseAdminUserListResultUser> SearchUsersAsync(int limit = int.MaxValue, int chunkLimit = 250,
         SynapseAdminLocalUserQueryFilter? localFilter = null) {
         // TODO: implement filters
         string? from = null;
@@ -127,7 +130,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
             if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
             Console.WriteLine($"--- ADMIN Querying User List with URL: {url} ---");
             // TODO: implement URI methods in http client
-            var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<AdminUserListResult>(url.ToString());
+            var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminUserListResult>(url.ToString());
             foreach (var user in res.Users) {
                 limit--;
                 yield return user;
@@ -171,5 +174,185 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
         }
     }
 
+    public async Task<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails> GetEventReportDetailsAsync(string reportId) {
+        var url = new Uri($"/_synapse/admin/v1/event_reports/{reportId.UrlEncode()}", UriKind.Relative);
+        return await authenticatedHomeserver.ClientHttpClient
+            .GetFromJsonAsync<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails>(url.ToString());
+    }
+
+    // Utility function to get details straight away
+    public async IAsyncEnumerable<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails> GetEventReportsWithDetailsAsync(int limit = int.MaxValue,
+        int chunkLimit = 250, string dir = "f", SynapseAdminLocalEventReportQueryFilter? filter = null) {
+        Queue<Task<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails>> tasks = [];
+        await foreach (var report in GetEventReportsAsync(limit, chunkLimit, dir, filter)) {
+            tasks.Enqueue(GetEventReportDetailsAsync(report.Id));
+            while (tasks.Peek().IsCompleted) yield return await tasks.Dequeue(); // early return if possible
+        }
+
+        while (tasks.Count > 0) yield return await tasks.Dequeue();
+    }
+
+    public async Task DeleteEventReportAsync(string reportId) {
+        var url = new Uri($"/_synapse/admin/v1/event_reports/{reportId.UrlEncode()}", UriKind.Relative);
+        await authenticatedHomeserver.ClientHttpClient.DeleteAsync(url.ToString());
+    }
+
+#endregion
+
+#region Background Updates
+
+    public async Task<bool> GetBackgroundUpdatesEnabledAsync() {
+        var url = new Uri("/_synapse/admin/v1/background_updates/enabled", UriKind.Relative);
+        // The return type is technically wrong, but includes the field we want.
+        var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(url.ToString());
+        return resp.Enabled;
+    }
+
+    public async Task<bool> SetBackgroundUpdatesEnabledAsync(bool enabled) {
+        var url = new Uri("/_synapse/admin/v1/background_updates/enabled", UriKind.Relative);
+        // The used types are technically wrong, but include the field we want.
+        var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new JsonObject {
+            ["enabled"] = enabled
+        });
+        var json = await resp.Content.ReadFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>();
+        return json!.Enabled;
+    }
+
+    public async Task<SynapseAdminBackgroundUpdateStatusResponse> GetBackgroundUpdatesStatusAsync() {
+        var url = new Uri("/_synapse/admin/v1/background_updates/status", UriKind.Relative);
+        return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(url.ToString());
+    }
+
+    /// <summary>
+    /// Run a background job
+    /// </summary>
+    /// <param name="jobName">One of "populate_stats_process_rooms" or "regenerate_directory"</param>
+    public async Task RunBackgroundJobsAsync(string jobName) {
+        var url = new Uri("/_synapse/admin/v1/background_updates/run", UriKind.Relative);
+        await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync(url.ToString(), new JsonObject() {
+            ["job_name"] = jobName
+        });
+    }
+
+#endregion
+
+#region Federation
+
+    public async IAsyncEnumerable<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination> GetFederationDestinationsAsync(int limit = int.MaxValue,
+        int chunkLimit = 250) {
+        string? from = null;
+        while (limit > 0) {
+            var url = new Uri("/_synapse/admin/v1/federation/destinations", UriKind.Relative);
+            url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+            if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+            Console.WriteLine($"--- ADMIN Querying Federation Destinations with URL: {url} ---");
+            var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationListResult>(url.ToString());
+            foreach (var dest in res.Destinations) {
+                limit--;
+                yield return dest;
+            }
+        }
+    }
+
+    public async Task<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination> GetFederationDestinationDetailsAsync(string destination) {
+        var url = new Uri($"/_synapse/admin/v1/federation/destinations/{destination}", UriKind.Relative);
+        return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination>(url.ToString());
+    }
+
+    public async IAsyncEnumerable<SynapseAdminDestinationRoomListResult.SynapseAdminDestinationRoomListResultRoom> GetFederationDestinationRoomsAsync(string destination,
+        int limit = int.MaxValue, int chunkLimit = 250) {
+        string? from = null;
+        while (limit > 0) {
+            var url = new Uri($"/_synapse/admin/v1/federation/destinations/{destination}/rooms", UriKind.Relative);
+            url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+            if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+            Console.WriteLine($"--- ADMIN Querying Federation Destination Rooms with URL: {url} ---");
+            var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationRoomListResult>(url.ToString());
+            foreach (var room in res.Rooms) {
+                limit--;
+                yield return room;
+            }
+        }
+    }
+
+    public async Task ResetFederationConnectionTimeoutAsync(string destination) {
+        await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/federation/destinations/{destination}/reset_connection", new JsonObject());
+    }
+
+#endregion
+
+#region Registration Tokens
+
+    // does not support pagination
+    public async Task<List<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>> GetRegistrationTokensAsync() {
+        var url = new Uri("/_synapse/admin/v1/registration_tokens", UriKind.Relative);
+        var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRegistrationTokenListResult>(url.ToString());
+        return resp.RegistrationTokens;
+    }
+
+    public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> GetRegistrationTokenAsync(string token) {
+        var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+        var resp =
+            await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>(url.ToString());
+        return resp;
+    }
+
+    public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> CreateRegistrationTokenAsync(
+        SynapseAdminRegistrationTokenCreateRequest request) {
+        var url = new Uri("/_synapse/admin/v1/", UriKind.Relative);
+        var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync(url.ToString(), request);
+        var token = await resp.Content.ReadFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>();
+        return token!;
+    }
+
+    public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> UpdateRegistrationTokenAsync(string token,
+        SynapseAdminRegistrationTokenUpdateRequest request) {
+        var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+        var resp = await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync(url.ToString(), request);
+        return await resp.Content.ReadFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>();
+    }
+
+    public async Task DeleteRegistrationTokenAsync(string token) {
+        var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+        await authenticatedHomeserver.ClientHttpClient.DeleteAsync(url.ToString());
+    }
+
+#endregion
+
+#region Account Validity
+
+    // Does anyone even use this?
+    // README: https://github.com/matrix-org/synapse/issues/15271
+    // -> Don't implement unless requested, if not for this feature almost never being used.
+
+#endregion
+
+#region Experimental Features
+
+    public async Task<Dictionary<string, bool>> GetExperimentalFeaturesAsync(string userId) {
+        var url = new Uri($"/_synapse/admin/v1/experimental_features/{userId.UrlEncode()}", UriKind.Relative);
+        var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<JsonObject>(url.ToString());
+        return resp["features"]!.GetValue<Dictionary<string, bool>>();
+    }
+
+    public async Task SetExperimentalFeaturesAsync(string userId, Dictionary<string, bool> features) {
+        var url = new Uri($"/_synapse/admin/v1/experimental_features/{userId.UrlEncode()}", UriKind.Relative);
+        await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new JsonObject {
+            ["features"] = JsonSerializer.Deserialize<JsonObject>(features.ToJson())
+        });
+    }
+
+#endregion
+
+#region Media
+
+    public async Task<SynapseAdminRoomMediaListResult> GetRoomMediaAsync(string roomId) {
+        var url = new Uri($"/_synapse/admin/v1/rooms/{roomId.UrlEncode()}/media", UriKind.Relative);
+        return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomMediaListResult>(url.ToString());
+    }
+
+    // This is in the user admin API section
+    // public async IAsyncEnumerable<SynapseAdminRoomMediaListResult>
+
 #endregion
 }
\ No newline at end of file
diff --git a/LibMatrix/Interfaces/Services/IStorageProvider.cs b/LibMatrix/Interfaces/Services/IStorageProvider.cs
index 165e7df..fb7bb6d 100644
--- a/LibMatrix/Interfaces/Services/IStorageProvider.cs
+++ b/LibMatrix/Interfaces/Services/IStorageProvider.cs
@@ -31,7 +31,7 @@ public interface IStorageProvider {
     }
 
     // get all keys
-    public Task<List<string>> GetAllKeysAsync() {
+    public Task<IEnumerable<string>> GetAllKeysAsync() {
         Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement GetAllKeys()!");
         throw new NotImplementedException();
     }
@@ -53,4 +53,18 @@ public interface IStorageProvider {
         Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement LoadStream(key)!");
         throw new NotImplementedException();
     }
+
+    // copy
+    public async Task CopyObjectAsync(string sourceKey, string destKey) {
+        Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement CopyObject(sourceKey, destKey), using load + save!");
+        var data = await LoadObjectAsync<object>(sourceKey);
+        await SaveObjectAsync(destKey, data);
+    }
+
+    // move
+    public async Task MoveObjectAsync(string sourceKey, string destKey) {
+        Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement MoveObject(sourceKey, destKey), using copy + delete!");
+        await CopyObjectAsync(sourceKey, destKey);
+        await DeleteObjectAsync(sourceKey);
+    }
 }
\ No newline at end of file
diff --git a/LibMatrix/Responses/SyncResponse.cs b/LibMatrix/Responses/SyncResponse.cs
index d807ecb..2d3d3f8 100644
--- a/LibMatrix/Responses/SyncResponse.cs
+++ b/LibMatrix/Responses/SyncResponse.cs
@@ -1,4 +1,5 @@
 using System.Text.Json.Serialization;
+using LibMatrix.EventTypes.Spec.Ephemeral;
 using LibMatrix.EventTypes.Spec.State;
 
 namespace LibMatrix.Responses;
@@ -60,10 +61,9 @@ public class SyncResponse {
             public EventList? State { get; set; }
 
             public override string ToString() {
-                var lastEvent = Timeline?.Events?.LastOrDefault(x=>x.Type == "m.room.member");
+                var lastEvent = Timeline?.Events?.LastOrDefault(x => x.Type == "m.room.member");
                 var membership = (lastEvent?.TypedContent as RoomMemberEventContent);
                 return $"LeftRoomDataStructure: {lastEvent?.Sender} {membership?.Membership} ({membership?.Reason})";
-                
             }
         }
 
@@ -129,4 +129,15 @@ public class SyncResponse {
             public EventList? InviteState { get; set; }
         }
     }
+
+    public long GetDerivedSyncTime() {
+        return ((long[]) [
+            AccountData?.Events?.Max(x => x.OriginServerTs) ?? 0,
+            Presence?.Events?.Max(x => x.OriginServerTs) ?? 0,
+            ToDevice?.Events?.Max(x => x.OriginServerTs) ?? 0,
+            Rooms?.Join?.Values?.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0,
+            Rooms?.Invite?.Values?.Max(x => x.InviteState?.Events?.Max(y => y.OriginServerTs)) ?? 0,
+            Rooms?.Leave?.Values?.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0
+        ]).Max();
+    }
 }
\ No newline at end of file
diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs
index fe2ee8d..4f6a4e9 100644
--- a/LibMatrix/RoomTypes/GenericRoom.cs
+++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -106,7 +106,7 @@ public class GenericRoom {
                 Console.WriteLine("WARNING: Homeserver does not support getting event ID from state events, falling back to sync");
                 var sh = new SyncHelper(Homeserver);
                 var emptyFilter = new SyncFilter.EventFilter(types: [], limit: 1, senders: [], notTypes: ["*"]);
-                var emptyStateFilter = new SyncFilter.RoomFilter.StateFilter(types: [], limit: 1, senders: [], notTypes: ["*"], rooms:[]);
+                var emptyStateFilter = new SyncFilter.RoomFilter.StateFilter(types: [], limit: 1, senders: [], notTypes: ["*"], rooms: []);
                 sh.Filter = new() {
                     Presence = emptyFilter,
                     AccountData = emptyFilter,
@@ -121,10 +121,11 @@ public class GenericRoom {
                 var sync = await sh.SyncAsync();
                 var state = sync.Rooms.Join[RoomId].State.Events;
                 var stateEvent = state.FirstOrDefault(x => x.Type == type && x.StateKey == stateKey);
-                if (stateEvent is null) throw new LibMatrixException() {
-                    ErrorCode = LibMatrixException.ErrorCodes.M_NOT_FOUND,
-                    Error = "State event not found in sync response"
-                };
+                if (stateEvent is null)
+                    throw new LibMatrixException() {
+                        ErrorCode = LibMatrixException.ErrorCodes.M_NOT_FOUND,
+                        Error = "State event not found in sync response"
+                    };
                 return stateEvent.EventId;
             }
 
@@ -231,7 +232,7 @@ public class GenericRoom {
         // var sw = Stopwatch.StartNew();
         var res = await Homeserver.ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{RoomId}/members");
         // if (sw.ElapsedMilliseconds > 1000)
-            // Console.WriteLine($"Members call responded in {sw.GetElapsedAndRestart()}");
+        // Console.WriteLine($"Members call responded in {sw.GetElapsedAndRestart()}");
         // else sw.Restart();
         // var resText = await res.Content.ReadAsStringAsync();
         // Console.WriteLine($"Members call response read in {sw.GetElapsedAndRestart()}");
@@ -239,7 +240,7 @@ public class GenericRoom {
             TypeInfoResolver = ChunkedStateEventResponseSerializerContext.Default
         });
         // if (sw.ElapsedMilliseconds > 100)
-            // Console.WriteLine($"Members call deserialised in {sw.GetElapsedAndRestart()}");
+        // Console.WriteLine($"Members call deserialised in {sw.GetElapsedAndRestart()}");
         // else sw.Restart();
         foreach (var resp in result.Chunk) {
             if (resp?.Type != "m.room.member") continue;
@@ -248,14 +249,14 @@ public class GenericRoom {
         }
 
         // if (sw.ElapsedMilliseconds > 100)
-            // Console.WriteLine($"Members call iterated in {sw.GetElapsedAndRestart()}");
+        // Console.WriteLine($"Members call iterated in {sw.GetElapsedAndRestart()}");
     }
 
     public async Task<FrozenSet<StateEventResponse>> GetMembersListAsync(bool joinedOnly = true) {
         // var sw = Stopwatch.StartNew();
         var res = await Homeserver.ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{RoomId}/members");
         // if (sw.ElapsedMilliseconds > 1000)
-            // Console.WriteLine($"Members call responded in {sw.GetElapsedAndRestart()}");
+        // Console.WriteLine($"Members call responded in {sw.GetElapsedAndRestart()}");
         // else sw.Restart();
         // var resText = await res.Content.ReadAsStringAsync();
         // Console.WriteLine($"Members call response read in {sw.GetElapsedAndRestart()}");
@@ -263,7 +264,7 @@ public class GenericRoom {
             TypeInfoResolver = ChunkedStateEventResponseSerializerContext.Default
         });
         // if (sw.ElapsedMilliseconds > 100)
-            // Console.WriteLine($"Members call deserialised in {sw.GetElapsedAndRestart()}");
+        // Console.WriteLine($"Members call deserialised in {sw.GetElapsedAndRestart()}");
         // else sw.Restart();
         var members = new List<StateEventResponse>();
         foreach (var resp in result.Chunk) {
@@ -273,7 +274,7 @@ public class GenericRoom {
         }
 
         // if (sw.ElapsedMilliseconds > 100)
-            // Console.WriteLine($"Members call iterated in {sw.GetElapsedAndRestart()}");
+        // Console.WriteLine($"Members call iterated in {sw.GetElapsedAndRestart()}");
         return members.ToFrozenSet();
     }
 
@@ -318,13 +319,16 @@ public class GenericRoom {
 
     public async Task<string> GetNameOrFallbackAsync(int maxMemberNames = 2) {
         try {
-            return await GetNameAsync();
+            var name = await GetNameAsync();
+            if (!string.IsNullOrWhiteSpace(name))
+                return name;
+            throw new Exception("No name");
         }
         catch {
             try {
                 var alias = await GetCanonicalAliasAsync();
-                if (alias?.Alias is not null) return alias.Alias;
-                throw new Exception("No name or alias");
+                if (!string.IsNullOrWhiteSpace(alias?.Alias)) return alias.Alias;
+                throw new Exception("No alias");
             }
             catch {
                 try {
@@ -332,7 +336,8 @@ public class GenericRoom {
                     var memberList = new List<string>();
                     var memberCount = 0;
                     await foreach (var member in members)
-                        memberList.Add(member.RawContent?["displayname"]?.GetValue<string>() ?? "");
+                        if (member.StateKey != Homeserver.UserId)
+                            memberList.Add(member.RawContent?["displayname"]?.GetValue<string>() ?? "");
                     memberCount = memberList.Count;
                     memberList.RemoveAll(string.IsNullOrWhiteSpace);
                     memberList = memberList.OrderBy(x => x).ToList();
@@ -524,7 +529,7 @@ public class GenericRoom {
 
         var uri = new Uri(path, UriKind.Relative);
         if (dir == "b" || dir == "f") uri = uri.AddQuery("dir", dir);
-        else if(!string.IsNullOrWhiteSpace(dir)) throw new ArgumentException("Invalid direction", nameof(dir));
+        else if (!string.IsNullOrWhiteSpace(dir)) throw new ArgumentException("Invalid direction", nameof(dir));
         if (!string.IsNullOrEmpty(from)) uri = uri.AddQuery("from", from);
         if (chunkLimit is not null) uri = uri.AddQuery("limit", chunkLimit.Value.ToString());
         if (recurse is not null) uri = uri.AddQuery("recurse", recurse.Value.ToString());
diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs
index 75d4b12..869e420 100644
--- a/LibMatrix/StateEvent.cs
+++ b/LibMatrix/StateEvent.cs
@@ -31,6 +31,23 @@ public class StateEvent {
     public static Type GetStateEventType(string? type) =>
         string.IsNullOrWhiteSpace(type) ? typeof(UnknownEventContent) : KnownStateEventTypesByName.GetValueOrDefault(type) ?? typeof(UnknownEventContent);
 
+    [JsonPropertyName("state_key")]
+    public string? StateKey { get; set; }
+
+    [JsonPropertyName("type")]
+    public string Type { get; set; }
+
+    [JsonPropertyName("replaces_state")]
+    public string? ReplacesState { get; set; }
+
+    private JsonObject? _rawContent;
+
+    [JsonPropertyName("content")]
+    public JsonObject? RawContent {
+        get => _rawContent;
+        set => _rawContent = value;
+    }
+
     [JsonIgnore]
     public Type MappedType => GetStateEventType(Type);
 
@@ -82,54 +99,6 @@ public class StateEvent {
         }
     }
 
-    [JsonPropertyName("state_key")]
-    public string? StateKey { get; set; }
-
-    [JsonPropertyName("type")]
-    public string Type { get; set; }
-
-    [JsonPropertyName("replaces_state")]
-    public string? ReplacesState { get; set; }
-
-    private JsonObject? _rawContent;
-
-    [JsonPropertyName("content")]
-    public JsonObject? RawContent {
-        get => _rawContent;
-        set => _rawContent = value;
-    }
-    //
-    // [JsonIgnore]
-    // public new Type GetType {
-    //     get {
-    //         var type = GetStateEventType(Type);
-    //
-    //         //special handling for some types
-    //         // if (type == typeof(RoomEmotesEventContent)) {
-    //         //     RawContent["emote"] = RawContent["emote"]?.AsObject() ?? new JsonObject();
-    //         // }
-    //         //
-    //         // if (this is StateEventResponse stateEventResponse) {
-    //         //     if (type == null || type == typeof(object)) {
-    //         //         Console.WriteLine($"Warning: unknown event type '{Type}'!");
-    //         //         Console.WriteLine(RawContent.ToJson());
-    //         //         Directory.CreateDirectory($"unknown_state_events/{Type}");
-    //         //         File.WriteAllText($"unknown_state_events/{Type}/{stateEventResponse.EventId}.json",
-    //         //             RawContent.ToJson());
-    //         //         Console.WriteLine($"Saved to unknown_state_events/{Type}/{stateEventResponse.EventId}.json");
-    //         //     }
-    //         //     else if (RawContent is not null && RawContent.FindExtraJsonObjectFields(type)) {
-    //         //         Directory.CreateDirectory($"unknown_state_events/{Type}");
-    //         //         File.WriteAllText($"unknown_state_events/{Type}/{stateEventResponse.EventId}.json",
-    //         //             RawContent.ToJson());
-    //         //         Console.WriteLine($"Saved to unknown_state_events/{Type}/{stateEventResponse.EventId}.json");
-    //         //     }
-    //         // }
-    //
-    //         return type;
-    //     }
-    // }
-
     //debug
     [JsonIgnore]
     public string InternalSelfTypeName {