about summary refs log tree commit diff
diff options
context:
space:
mode:
authorTheArcaneBrony <myrainbowdash949@gmail.com>2023-10-06 18:29:15 +0200
committerTheArcaneBrony <myrainbowdash949@gmail.com>2023-10-06 18:29:15 +0200
commite5591eef3850a9796cc87386128651a828b70697 (patch)
tree7e501ec749229a3d96838265289266bb6f6ce208
parentUnit tests, small refactors (diff)
downloadLibMatrix-e5591eef3850a9796cc87386128651a828b70697.tar.xz
Small refactors
-rw-r--r--ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs9
-rw-r--r--ExampleBots/MediaModeratorPoC/AccountData/BotData.cs (renamed from ExampleBots/MediaModeratorPoC/Bot/AccountData/BotData.cs)5
-rw-r--r--ExampleBots/MediaModeratorPoC/Commands/BanMediaCommand.cs (renamed from ExampleBots/MediaModeratorPoC/Bot/Commands/BanMediaCommand.cs)24
-rw-r--r--ExampleBots/MediaModeratorPoC/MediaModBot.cs (renamed from ExampleBots/MediaModeratorPoC/Bot/MediaModBot.cs)44
-rw-r--r--ExampleBots/MediaModeratorPoC/MediaModBotConfiguration.cs (renamed from ExampleBots/MediaModeratorPoC/Bot/MediaModBotConfiguration.cs)2
-rw-r--r--ExampleBots/MediaModeratorPoC/PolicyEngine.cs86
-rw-r--r--ExampleBots/MediaModeratorPoC/PolicyList.cs17
-rw-r--r--ExampleBots/MediaModeratorPoC/Program.cs2
-rw-r--r--ExampleBots/MediaModeratorPoC/StateEventTypes/BasePolicy.cs (renamed from ExampleBots/MediaModeratorPoC/Bot/StateEventTypes/MediaPolicyStateEventData.cs)27
-rw-r--r--ExampleBots/MediaModeratorPoC/StateEventTypes/MediaPolicyStateEventData.cs17
-rw-r--r--ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs1
-rw-r--r--ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs1
-rw-r--r--ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs2
-rw-r--r--ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs10
-rw-r--r--LibMatrix/EventTypes/Common/MjolnirShortcodeEventContent.cs (renamed from LibMatrix/EventTypes/Common/MjolnirShortcodeEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Common/RoomEmotesEventContent.cs (renamed from LibMatrix/EventTypes/Common/RoomEmotesEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/MatrixEventAttribute.cs (renamed from LibMatrix/Helpers/MatrixEventAttribute.cs)2
-rw-r--r--LibMatrix/EventTypes/Spec/Ephemeral/PresenceStateEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/PresenceStateEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomTypingEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/RoomMessageEventContent.cs (renamed from LibMatrix/EventTypes/Spec/RoomMessageEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/PolicyRuleStateEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/ProfileResponseEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/ProfileResponseEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAliasEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomAliasEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAvatarEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomAvatarEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/CanonicalAliasEventContent.cs)2
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomCreateEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomEncryptionEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/GuestAccessEventData.cs)2
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/HistoryVisibilityEventData.cs)2
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/JoinRulesEventData.cs)2
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomMemberEventData.cs)2
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomNameEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomPinnedEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomPowerLevelEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/ServerACLEventData.cs)2
-rw-r--r--LibMatrix/EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/RoomTopicEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/Space/SpaceChildEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/SpaceChildEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/Spec/State/Space/SpaceParentEventContent.cs (renamed from LibMatrix/EventTypes/Spec/State/SpaceParentEventData.cs)0
-rw-r--r--LibMatrix/EventTypes/UnknownStateEventContent.cs (renamed from LibMatrix/EventTypes/UnknownStateEventData.cs)0
-rw-r--r--LibMatrix/Extensions/EnumerableExtensions.cs28
-rw-r--r--LibMatrix/Extensions/HttpClientExtensions.cs14
-rw-r--r--LibMatrix/Helpers/MessageFormatter.cs9
-rw-r--r--LibMatrix/Helpers/SyncHelper.cs259
-rw-r--r--LibMatrix/Helpers/SyncStateResolver.cs174
-rw-r--r--LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs42
-rw-r--r--LibMatrix/Homeservers/RemoteHomeServer.cs16
-rw-r--r--LibMatrix/Interfaces/EventContent.cs (renamed from LibMatrix/Interfaces/IStateEventType.cs)5
-rw-r--r--LibMatrix/Responses/CreateRoomRequest.cs1
-rw-r--r--LibMatrix/Responses/LoginResponse.cs2
-rw-r--r--LibMatrix/Responses/StateEventResponse.cs52
-rw-r--r--LibMatrix/Responses/SyncResponse.cs118
-rw-r--r--LibMatrix/RoomTypes/GenericRoom.cs35
-rw-r--r--LibMatrix/Services/HomeserverProviderService.cs11
-rw-r--r--LibMatrix/Services/HomeserverResolverService.cs4
-rw-r--r--LibMatrix/StateEvent.cs62
-rw-r--r--Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs42
-rw-r--r--Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs34
-rw-r--r--Tests/LibMatrix.Tests/Tests/RoomEventTests.cs160
-rw-r--r--Tests/LibMatrix.Tests/Tests/RoomTests.cs145
-rw-r--r--Tests/LibMatrix.Tests/Tests/TestCleanup.cs76
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs5
61 files changed, 1034 insertions, 521 deletions
diff --git a/ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs b/ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs
index 0211f74..8cf4f1f 100644
--- a/ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs
+++ b/ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs
@@ -4,6 +4,7 @@ using LibMatrix.EventTypes.Spec;
 using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.ExampleBot.Bot.Interfaces;
 using LibMatrix.Extensions;
+using LibMatrix.Helpers;
 using LibMatrix.Homeservers;
 using LibMatrix.Services;
 using Microsoft.Extensions.DependencyInjection;
@@ -44,6 +45,8 @@ public class MRUBot : IHostedService {
             throw;
         }
 
+        var syncHelper = new SyncHelper(hs);
+
         await (hs.GetRoom("!DoHEdFablOLjddKWIp:rory.gay")).JoinAsync();
 
         // foreach (var room in await hs.GetJoinedRooms()) {
@@ -54,7 +57,7 @@ public class MRUBot : IHostedService {
         //     _logger.LogInformation($"Got room state for {room.RoomId}!");
         // }
 
-        hs.SyncHelper.InviteReceivedHandlers.Add(async Task (args) => {
+        syncHelper.InviteReceivedHandlers.Add(async Task (args) => {
             var inviteEvent =
                 args.Value.InviteState.Events.FirstOrDefault(x =>
                     x.Type == "m.room.member" && x.StateKey == hs.UserId);
@@ -71,7 +74,7 @@ public class MRUBot : IHostedService {
                 }
             }
         });
-        hs.SyncHelper.TimelineEventHandlers.Add(async @event => {
+        syncHelper.TimelineEventHandlers.Add(async @event => {
             _logger.LogInformation(
                 "Got timeline event in {}: {}", @event.RoomId, @event.ToJson(indent: false, ignoreNull: true));
 
@@ -100,7 +103,7 @@ public class MRUBot : IHostedService {
                 }
             }
         });
-        await hs.SyncHelper.RunSyncLoop(cancellationToken: cancellationToken);
+        await syncHelper.RunSyncLoopAsync(cancellationToken: cancellationToken);
     }
 
     /// <summary>Triggered when the application host is performing a graceful shutdown.</summary>
diff --git a/ExampleBots/MediaModeratorPoC/Bot/AccountData/BotData.cs b/ExampleBots/MediaModeratorPoC/AccountData/BotData.cs
index b4e1167..0fee4eb 100644
--- a/ExampleBots/MediaModeratorPoC/Bot/AccountData/BotData.cs
+++ b/ExampleBots/MediaModeratorPoC/AccountData/BotData.cs
@@ -1,6 +1,6 @@
 using System.Text.Json.Serialization;
 
-namespace MediaModeratorPoC.Bot.AccountData;
+namespace MediaModeratorPoC.AccountData;
 
 public class BotData {
     [JsonPropertyName("control_room")]
@@ -8,7 +8,4 @@ public class BotData {
 
     [JsonPropertyName("log_room")]
     public string? LogRoom { get; set; } = "";
-
-    [JsonPropertyName("policy_room")]
-    public string? PolicyRoom { get; set; } = "";
 }
diff --git a/ExampleBots/MediaModeratorPoC/Bot/Commands/BanMediaCommand.cs b/ExampleBots/MediaModeratorPoC/Commands/BanMediaCommand.cs
index fd6866c..69c0583 100644
--- a/ExampleBots/MediaModeratorPoC/Bot/Commands/BanMediaCommand.cs
+++ b/ExampleBots/MediaModeratorPoC/Commands/BanMediaCommand.cs
@@ -1,14 +1,14 @@
 using System.Security.Cryptography;
 using ArcaneLibs.Extensions;
+using LibMatrix;
 using LibMatrix.EventTypes.Spec;
 using LibMatrix.Helpers;
-using LibMatrix.Responses;
 using LibMatrix.Services;
 using LibMatrix.Utilities.Bot.Interfaces;
-using MediaModeratorPoC.Bot.AccountData;
-using MediaModeratorPoC.Bot.StateEventTypes;
+using MediaModeratorPoC.AccountData;
+using MediaModeratorPoC.StateEventTypes;
 
-namespace MediaModeratorPoC.Bot.Commands;
+namespace MediaModeratorPoC.Commands;
 
 public class BanMediaCommand(IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver) : ICommand {
     public string Name { get; } = "banmedia";
@@ -16,7 +16,7 @@ public class BanMediaCommand(IServiceProvider services, HomeserverProviderServic
 
     public async Task<bool> CanInvoke(CommandContext ctx) {
         //check if user is admin in control room
-        var botData = await ctx.Homeserver.GetAccountData<BotData>("gay.rory.media_moderator_poc_data");
+        var botData = await ctx.Homeserver.GetAccountDataAsync<BotData>("gay.rory.modbot_data");
         var controlRoom = ctx.Homeserver.GetRoom(botData.ControlRoom);
         var isAdmin = (await controlRoom.GetPowerLevelsAsync())!.UserHasPermission(ctx.MessageEvent.Sender, "m.room.ban");
         if (!isAdmin) {
@@ -29,7 +29,7 @@ public class BanMediaCommand(IServiceProvider services, HomeserverProviderServic
     }
 
     public async Task Invoke(CommandContext ctx) {
-        var botData = await ctx.Homeserver.GetAccountData<BotData>("gay.rory.media_moderator_poc_data");
+        var botData = await ctx.Homeserver.GetAccountDataAsync<BotData>("gay.rory.modbot_data");
         var policyRoom = ctx.Homeserver.GetRoom(botData.PolicyRoom ?? botData.ControlRoom);
         var logRoom = ctx.Homeserver.GetRoom(botData.LogRoom ?? botData.ControlRoom);
 
@@ -54,7 +54,9 @@ public class BanMediaCommand(IServiceProvider services, HomeserverProviderServic
                 var recommendation = ctx.Args[0];
 
                 if (recommendation is not ("ban" or "kick" or "mute" or "redact" or "spoiler" or "warn" or "warn_admins")) {
-                    await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatError($"Invalid recommendation type {recommendation}, must be `warn_admins`, `warn`, `spoiler`, `redact`, `mute`, `kick` or `ban`!"));
+                    await ctx.Room.SendMessageEventAsync(
+                        MessageFormatter.FormatError(
+                            $"Invalid recommendation type {recommendation}, must be `warn_admins`, `warn`, `spoiler`, `redact`, `mute`, `kick` or `ban`!"));
                     return;
                 }
 
@@ -70,16 +72,16 @@ public class BanMediaCommand(IServiceProvider services, HomeserverProviderServic
                 }
                 catch (Exception ex) {
                     await logRoom.SendMessageEventAsync(
-                        MessageFormatter.FormatException($"Error calculating file hash for {mxcUri} via {mxcUri.Split('/')[2]}, retrying via {ctx.Homeserver.HomeServerDomain}...",
+                        MessageFormatter.FormatException($"Error calculating file hash for {mxcUri} via {mxcUri.Split('/')[2]}, retrying via {ctx.Homeserver.BaseUrl}...",
                             ex));
                     try {
-                        resolvedUri = await hsResolver.ResolveMediaUri(ctx.Homeserver.HomeServerDomain, mxcUri);
+                        resolvedUri = await hsResolver.ResolveMediaUri(ctx.Homeserver.BaseUrl, mxcUri);
                         fileHash = await hashAlgo.ComputeHashAsync(await ctx.Homeserver._httpClient.GetStreamAsync(resolvedUri));
                     }
                     catch (Exception ex2) {
                         await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatException("Error calculating file hash", ex2));
                         await logRoom.SendMessageEventAsync(
-                            MessageFormatter.FormatException($"Error calculating file hash via {ctx.Homeserver.HomeServerDomain}!", ex2));
+                            MessageFormatter.FormatException($"Error calculating file hash via {ctx.Homeserver.BaseUrl}!", ex2));
                     }
                 }
 
@@ -98,7 +100,7 @@ public class BanMediaCommand(IServiceProvider services, HomeserverProviderServic
                 await logRoom.SendMessageEventAsync(MessageFormatter.FormatException("Error creating policy", e));
                 await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatException("Error creating policy", e));
                 await using var stream = new MemoryStream(e.ToString().AsBytes().ToArray());
-                await logRoom.SendFileAsync("m.file", "error.log.cs", stream);
+                await logRoom.SendFileAsync("error.log.cs", stream);
             }
         }
         else {
diff --git a/ExampleBots/MediaModeratorPoC/Bot/MediaModBot.cs b/ExampleBots/MediaModeratorPoC/MediaModBot.cs
index f9bbcf3..0aacf61 100644
--- a/ExampleBots/MediaModeratorPoC/Bot/MediaModBot.cs
+++ b/ExampleBots/MediaModeratorPoC/MediaModBot.cs
@@ -1,8 +1,4 @@
-using System.Buffers.Text;
-using System.Data;
 using System.Security.Cryptography;
-using System.Text;
-using System.Text.Encodings.Web;
 using System.Text.RegularExpressions;
 using ArcaneLibs.Extensions;
 using LibMatrix;
@@ -14,12 +10,12 @@ using LibMatrix.Responses;
 using LibMatrix.RoomTypes;
 using LibMatrix.Services;
 using LibMatrix.Utilities.Bot.Interfaces;
-using MediaModeratorPoC.Bot.AccountData;
-using MediaModeratorPoC.Bot.StateEventTypes;
+using MediaModeratorPoC.AccountData;
+using MediaModeratorPoC.StateEventTypes;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 
-namespace MediaModeratorPoC.Bot;
+namespace MediaModeratorPoC;
 
 public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot> logger, MediaModBotConfiguration configuration,
     HomeserverResolverService hsResolver) : IHostedService {
@@ -31,6 +27,8 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
     private GenericRoom _logRoom;
     private GenericRoom _controlRoom;
 
+
+
     /// <summary>Triggered when the application host is ready to start the service.</summary>
     /// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
     public async Task StartAsync(CancellationToken cancellationToken) {
@@ -44,7 +42,7 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
         BotData botData;
 
         try {
-            botData = await hs.GetAccountData<BotData>("gay.rory.media_moderator_poc_data");
+            botData = await hs.GetAccountDataAsync<BotData>("gay.rory.modbot_data");
         }
         catch (Exception e) {
             if (e is not MatrixException { ErrorCode: "M_NOT_FOUND" }) {
@@ -63,10 +61,10 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
             creationContent.InitialState.Add(new StateEvent {
                 Type = "m.room.join_rules",
                 StateKey = "",
-                TypedContent = new JoinRulesEventContent {
+                TypedContent = new RoomJoinRulesEventContent {
                     JoinRule = "knock_restricted",
                     Allow = new() {
-                        new JoinRulesEventContent.AllowEntry {
+                        new RoomJoinRulesEventContent.AllowEntry {
                             Type = "m.room_membership",
                             RoomId = botData.ControlRoom
                         }
@@ -84,12 +82,13 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
             creationContent.CreationContent["type"] = "gay.rory.media_moderator_poc.policy_room";
             botData.PolicyRoom = (await hs.CreateRoom(creationContent)).RoomId;
 
-            await hs.SetAccountData("gay.rory.media_moderator_poc_data", botData);
+            await hs.SetAccountData("gay.rory.modbot_data", botData);
         }
 
         _policyRoom = hs.GetRoom(botData.PolicyRoom ?? botData.ControlRoom);
         _logRoom = hs.GetRoom(botData.LogRoom ?? botData.ControlRoom);
         _controlRoom = hs.GetRoom(botData.ControlRoom);
+        var syncHelper = new SyncHelper(hs);
 
         List<string> admins = new();
 
@@ -98,7 +97,9 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
             while (!cancellationToken.IsCancellationRequested) {
                 var controlRoomMembers = _controlRoom.GetMembersAsync();
                 await foreach (var member in controlRoomMembers) {
-                    if ((member.TypedContent as RoomMemberEventContent).Membership == "join") admins.Add(member.UserId);
+                    if ((member.TypedContent as RoomMemberEventContent)?
+
+                        .Membership == "join") admins.Add(member.UserId);
                 }
 
                 await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken);
@@ -106,13 +107,12 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
         }, cancellationToken);
 #pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
 
-        hs.SyncHelper.InviteReceivedHandlers.Add(async Task (args) => {
+        syncHelper.InviteReceivedHandlers.Add(async Task (args) => {
             var inviteEvent =
                 args.Value.InviteState.Events.FirstOrDefault(x =>
                     x.Type == "m.room.member" && x.StateKey == hs.UserId);
-            logger.LogInformation(
-                $"Got invite to {args.Key} by {inviteEvent.Sender} with reason: {(inviteEvent.TypedContent as RoomMemberEventContent).Reason}");
-            if (inviteEvent.Sender.EndsWith(":rory.gay") || inviteEvent.Sender.EndsWith(":conduit.rory.gay")) {
+            logger.LogInformation("Got invite to {RoomId} by {Sender} with reason: {Reason}", args.Key, inviteEvent!.Sender, (inviteEvent.TypedContent as RoomMemberEventContent)!.Reason);
+            if (inviteEvent.Sender.EndsWith(":rory.gay") || inviteEvent!.Sender.EndsWith(":conduit.rory.gay")) {
                 try {
                     var senderProfile = await hs.GetProfileAsync(inviteEvent.Sender);
                     await hs.GetRoom(args.Key).JoinAsync(reason: $"I was invited by {senderProfile.DisplayName ?? inviteEvent.Sender}!");
@@ -124,7 +124,7 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
             }
         });
 
-        hs.SyncHelper.TimelineEventHandlers.Add(async @event => {
+        syncHelper.TimelineEventHandlers.Add(async @event => {
             var room = hs.GetRoom(@event.RoomId);
             try {
                 logger.LogInformation(
@@ -256,8 +256,8 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
                 await _logRoom.SendMessageEventAsync(
                     MessageFormatter.FormatException($"Unable to ban user in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e));
                 await using var stream = new MemoryStream(e.ToString().AsBytes().ToArray());
-                await _controlRoom.SendFileAsync("m.file", "error.log.cs", stream);
-                await _logRoom.SendFileAsync("m.file", "error.log.cs", stream);
+                await _controlRoom.SendFileAsync("error.log.cs", stream);
+                await _logRoom.SendFileAsync("error.log.cs", stream);
             }
         });
     }
@@ -282,15 +282,15 @@ public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot>
         }
         catch (Exception ex) {
             await _logRoom.SendMessageEventAsync(
-                MessageFormatter.FormatException($"Error calculating file hash for {mxcUri} via {mxcUri.Split('/')[2]} ({resolvedUri}), retrying via {hs.HomeServerDomain}...",
+                MessageFormatter.FormatException($"Error calculating file hash for {mxcUri} via {mxcUri.Split('/')[2]} ({resolvedUri}), retrying via {hs.BaseUrl}...",
                     ex));
             try {
-                resolvedUri = await hsResolver.ResolveMediaUri(hs.HomeServerDomain, mxcUri);
+                resolvedUri = await hsResolver.ResolveMediaUri(hs.BaseUrl, mxcUri);
                 fileHash = await hashAlgo.ComputeHashAsync(await hs._httpClient.GetStreamAsync(resolvedUri));
             }
             catch (Exception ex2) {
                 await _logRoom.SendMessageEventAsync(
-                    MessageFormatter.FormatException($"Error calculating file hash via {hs.HomeServerDomain} ({resolvedUri})!", ex2));
+                    MessageFormatter.FormatException($"Error calculating file hash via {hs.BaseUrl} ({resolvedUri})!", ex2));
             }
         }
 
diff --git a/ExampleBots/MediaModeratorPoC/Bot/MediaModBotConfiguration.cs b/ExampleBots/MediaModeratorPoC/MediaModBotConfiguration.cs
index d848abe..cb5b596 100644
--- a/ExampleBots/MediaModeratorPoC/Bot/MediaModBotConfiguration.cs
+++ b/ExampleBots/MediaModeratorPoC/MediaModBotConfiguration.cs
@@ -1,6 +1,6 @@
 using Microsoft.Extensions.Configuration;
 
-namespace MediaModeratorPoC.Bot;
+namespace MediaModeratorPoC;
 
 public class MediaModBotConfiguration {
     public MediaModBotConfiguration(IConfiguration config) => config.GetRequiredSection("MediaMod").Bind(this);
diff --git a/ExampleBots/MediaModeratorPoC/PolicyEngine.cs b/ExampleBots/MediaModeratorPoC/PolicyEngine.cs
new file mode 100644
index 0000000..0a0a565
--- /dev/null
+++ b/ExampleBots/MediaModeratorPoC/PolicyEngine.cs
@@ -0,0 +1,86 @@
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.RoomTypes;
+using LibMatrix.Services;
+using MediaModeratorPoC.AccountData;
+using MediaModeratorPoC.StateEventTypes;
+using Microsoft.Extensions.Logging;
+
+namespace MediaModeratorPoC;
+
+public class PolicyEngine(AuthenticatedHomeserverGeneric hs, ILogger<MediaModBot> logger, MediaModBotConfiguration configuration,
+    HomeserverResolverService hsResolver) {
+    public List<PolicyList> ActivePolicyLists { get; set; } = new();
+    private GenericRoom? _logRoom;
+    private GenericRoom? _controlRoom;
+
+    public async Task ReloadActivePolicyLists() {
+        // first time init
+        if (_logRoom is null || _controlRoom is null) {
+            var botData = await hs.GetAccountDataAsync<BotData>("gay.rory.modbot_data");
+            _logRoom ??= hs.GetRoom(botData.LogRoom ?? botData.ControlRoom);
+            _controlRoom ??= hs.GetRoom(botData.ControlRoom);
+        }
+
+        await _controlRoom?.SendMessageEventAsync(MessageFormatter.FormatSuccess("Reloading policy lists!"))!;
+        await _logRoom?.SendMessageEventAsync(
+            new RoomMessageEventContent(
+                body: "Reloading policy lists!",
+                messageType: "m.text"))!;
+
+        await _controlRoom?.SendMessageEventAsync(MessageFormatter.FormatSuccess("0/? policy lists loaded"))!;
+
+        var policyLists = new List<PolicyList>();
+        var policyListAccountData = await hs.GetAccountDataAsync<Dictionary<string, PolicyList>>("gay.rory.modbot.policy_lists");
+        foreach (var (roomId, policyList) in policyListAccountData) {
+            _logRoom?.SendMessageEventAsync(
+                new RoomMessageEventContent(
+                    body: $"Loading policy list {MessageFormatter.HtmlFormatMention(roomId)}!",
+                    messageType: "m.text"));
+            var room = hs.GetRoom(roomId);
+
+            policyList.Room = room;
+
+            var stateEvents = room.GetFullStateAsync();
+            await foreach (var stateEvent in stateEvents) {
+                if (stateEvent != null && stateEvent.GetType.IsAssignableTo(typeof(BasePolicy))) {
+                    policyList.Policies.Add(stateEvent);
+                }
+            }
+
+            //html table of policy count by type
+            var policyCount = policyList.Policies.GroupBy(x => x.Type).ToDictionary(x => x.Key, x => x.Count());
+            var policyCountTable = policyCount.Aggregate(
+                "<table><tr><th>Policy Type</th><th>Count</th></tr>",
+                (current, policy) => current + $"<tr><td>{policy.Key}</td><td>{policy.Value}</td></tr>");
+            policyCountTable += "</table>";
+
+            var policyCountTablePlainText = policyCount.Aggregate(
+                "Policy Type       | Count\n",
+                (current, policy) => current + $"{policy.Key,-16} | {policy.Value}\n");
+            await _logRoom?.SendMessageEventAsync(
+                new RoomMessageEventContent() {
+                    MessageType = "org.matrix.custom.html",
+                    Body = $"Policy count for {roomId}:\n{policyCountTablePlainText}",
+                    FormattedBody = $"Policy count for {MessageFormatter.HtmlFormatMention(roomId)}:\n{policyCountTable}",
+                })!;
+
+            await _logRoom?.SendMessageEventAsync(
+                new RoomMessageEventContent(
+                    body: $"Loaded {policyList.Policies.Count} policies for {MessageFormatter.HtmlFormatMention(roomId)}!",
+                    messageType: "m.text"))!;
+
+            policyLists.Add(policyList);
+
+            var progressMsgContent = MessageFormatter.FormatSuccess($"{policyLists.Count}/{policyListAccountData.Count} policy lists loaded");
+            //edit old message
+            progressMsgContent.RelatesTo = new() {
+
+            };
+            _controlRoom?.SendMessageEventAsync(progressMsgContent);
+        }
+
+        ActivePolicyLists = policyLists;
+    }
+}
diff --git a/ExampleBots/MediaModeratorPoC/PolicyList.cs b/ExampleBots/MediaModeratorPoC/PolicyList.cs
new file mode 100644
index 0000000..0f49c97
--- /dev/null
+++ b/ExampleBots/MediaModeratorPoC/PolicyList.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using LibMatrix;
+using LibMatrix.RoomTypes;
+using MediaModeratorPoC.StateEventTypes;
+
+namespace MediaModeratorPoC;
+
+public class PolicyList {
+    [JsonIgnore]
+    public GenericRoom Room { get; set; }
+
+    [JsonPropertyName("trusted")]
+    public bool Trusted { get; set; } = false;
+
+    [JsonIgnore]
+    public List<StateEvent> Policies { get; set; } = new();
+}
diff --git a/ExampleBots/MediaModeratorPoC/Program.cs b/ExampleBots/MediaModeratorPoC/Program.cs
index 413d91d..5b8e734 100644
--- a/ExampleBots/MediaModeratorPoC/Program.cs
+++ b/ExampleBots/MediaModeratorPoC/Program.cs
@@ -2,7 +2,7 @@
 

 using LibMatrix.Services;

 using LibMatrix.Utilities.Bot;

-using MediaModeratorPoC.Bot;

+using MediaModeratorPoC;

 using Microsoft.Extensions.DependencyInjection;

 using Microsoft.Extensions.Hosting;

 

diff --git a/ExampleBots/MediaModeratorPoC/Bot/StateEventTypes/MediaPolicyStateEventData.cs b/ExampleBots/MediaModeratorPoC/StateEventTypes/BasePolicy.cs
index 0096c78..048c1d0 100644
--- a/ExampleBots/MediaModeratorPoC/Bot/StateEventTypes/MediaPolicyStateEventData.cs
+++ b/ExampleBots/MediaModeratorPoC/StateEventTypes/BasePolicy.cs
@@ -1,27 +1,18 @@
+using System.ComponentModel.DataAnnotations;
 using System.Text.Json.Serialization;
-using LibMatrix.Helpers;
-using LibMatrix.Interfaces;
+using LibMatrix;
 
-namespace MediaModeratorPoC.Bot.StateEventTypes;
+namespace MediaModeratorPoC.StateEventTypes;
 
-[
-    MatrixEvent(EventName = "gay.rory.media_moderator_poc.rule.homeserver")]
-[MatrixEvent(EventName = "gay.rory.media_moderator_poc.rule.media")]
-public class MediaPolicyEventContent : EventContent {
+public abstract class BasePolicy : StateEvent {
     /// <summary>
-    ///     This is an MXC URI, hashed with SHA3-256.
+    ///     Entity this policy applies to
     /// </summary>
     [JsonPropertyName("entity")]
-    public byte[] Entity { get; set; }
+    public string Entity { get; set; }
 
     /// <summary>
-    /// Server this ban applies to, can use * and ? as globs.
-    /// </summary>
-    [JsonPropertyName("server_entity")]
-    public string? ServerEntity { get; set; }
-
-    /// <summary>
-    ///     Reason this user is banned
+    ///     Reason this policy exists
     /// </summary>
     [JsonPropertyName("reason")]
     public string? Reason { get; set; }
@@ -30,6 +21,7 @@ public class MediaPolicyEventContent : EventContent {
     ///     Suggested action to take, one of `ban`, `kick`, `mute`, `redact`, `spoiler`, `warn` or `warn_admins`
     /// </summary>
     [JsonPropertyName("recommendation")]
+    [AllowedValues("ban", "kick", "mute", "redact", "spoiler", "warn", "warn_admins")]
     public string Recommendation { get; set; } = "warn";
 
     /// <summary>
@@ -47,7 +39,4 @@ public class MediaPolicyEventContent : EventContent {
         get => Expiry == null ? null : DateTimeOffset.FromUnixTimeMilliseconds(Expiry.Value).DateTime;
         set => Expiry = value is null ? null : ((DateTimeOffset)value).ToUnixTimeMilliseconds();
     }
-
-    [JsonPropertyName("file_hash")]
-    public byte[]? FileHash { get; set; }
 }
diff --git a/ExampleBots/MediaModeratorPoC/StateEventTypes/MediaPolicyStateEventData.cs b/ExampleBots/MediaModeratorPoC/StateEventTypes/MediaPolicyStateEventData.cs
new file mode 100644
index 0000000..603a858
--- /dev/null
+++ b/ExampleBots/MediaModeratorPoC/StateEventTypes/MediaPolicyStateEventData.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using LibMatrix.EventTypes;
+
+namespace MediaModeratorPoC.StateEventTypes;
+
+/// <summary>
+///     File policy event, entity is the MXC URI of the file, hashed with SHA3-256.
+/// </summary>
+[MatrixEvent(EventName = "gay.rory.media_moderator_poc.rule.homeserver")]
+[MatrixEvent(EventName = "gay.rory.media_moderator_poc.rule.media")]
+public class MediaPolicyEventContent : BasePolicy {
+    /// <summary>
+    ///     Hash of the file
+    /// </summary>
+    [JsonPropertyName("file_hash")]
+    public byte[]? FileHash { get; set; }
+}
diff --git a/ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs b/ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs
index 9477488..5d11432 100644
--- a/ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs
+++ b/ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs
@@ -1,4 +1,5 @@
 using System.Text.Json.Serialization;
+using LibMatrix.EventTypes;
 using LibMatrix.Helpers;
 using LibMatrix.Interfaces;
 
diff --git a/ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs b/ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs
index 5edfc0e..42edd23 100644
--- a/ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs
+++ b/ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs
@@ -1,4 +1,5 @@
 using System.Text.Json.Serialization;
+using LibMatrix.EventTypes;
 using LibMatrix.Helpers;
 using LibMatrix.Interfaces;
 
diff --git a/ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs b/ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs
index 55624a8..4a7d646 100644
--- a/ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs
+++ b/ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs
@@ -25,7 +25,7 @@ public class CreateSystemCommand(IServiceProvider services, HomeserverProviderSe
         var sysName = ctx.Args[0];
         try {
             try {
-                await ctx.Homeserver.GetAccountData<BotData>("gay.rory.plural_contact_bot.system_data");
+                await ctx.Homeserver.GetAccountDataAsync<BotData>("gay.rory.plural_contact_bot.system_data");
                 await ctx.Reply(MessageFormatter.FormatError($"System {sysName} already exists!"));
             }
             catch (MatrixException e) {
diff --git a/ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs b/ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs
index 0bd2bbf..231af95 100644
--- a/ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs
+++ b/ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs
@@ -38,14 +38,16 @@ public class PluralContactBot(AuthenticatedHomeserverGeneric hs, ILogger<PluralC
 
         _logRoom = hs.GetRoom(botConfiguration.LogRoom);
 
-        hs.SyncHelper.InviteReceivedHandlers.Add(async Task (args) => {
+        var syncHelper = new SyncHelper(hs);
+
+        syncHelper.InviteReceivedHandlers.Add(async Task (args) => {
             var inviteEvent =
                 args.Value.InviteState.Events.FirstOrDefault(x =>
                     x.Type == "m.room.member" && x.StateKey == hs.UserId);
             logger.LogInformation("Got invite to {} by {} with reason: {}", args.Key, inviteEvent.Sender, (inviteEvent.TypedContent as RoomMemberEventContent).Reason);
 
             try {
-                var accountData = await hs.GetAccountData<SystemData>($"gay.rory.plural_contact_bot.system_data#{inviteEvent.StateKey}");
+                var accountData = await hs.GetAccountDataAsync<SystemData>($"gay.rory.plural_contact_bot.system_data#{inviteEvent.StateKey}");
                 if (accountData.Members.Contains(inviteEvent.Sender)) {
                     await (hs.GetRoom(args.Key)).JoinAsync(reason: "I was invited by a system member!");
 
@@ -74,7 +76,7 @@ public class PluralContactBot(AuthenticatedHomeserverGeneric hs, ILogger<PluralC
             }
         });
 
-        hs.SyncHelper.TimelineEventHandlers.Add(async @event => {
+        syncHelper.TimelineEventHandlers.Add(async @event => {
             var room = hs.GetRoom(@event.RoomId);
             try {
                 logger.LogInformation(
@@ -87,7 +89,7 @@ public class PluralContactBot(AuthenticatedHomeserverGeneric hs, ILogger<PluralC
                 await _logRoom.SendMessageEventAsync(
                     MessageFormatter.FormatException($"Exception handling event {@event.EventId} by {@event.Sender} in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e));
                 await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(e.ToString()));
-                await _logRoom.SendFileAsync("m.file", "error.log.cs", stream);
+                await _logRoom.SendFileAsync("error.log.cs", stream);
             }
         });
     }
diff --git a/LibMatrix/EventTypes/Common/MjolnirShortcodeEventData.cs b/LibMatrix/EventTypes/Common/MjolnirShortcodeEventContent.cs
index 9067351..9067351 100644
--- a/LibMatrix/EventTypes/Common/MjolnirShortcodeEventData.cs
+++ b/LibMatrix/EventTypes/Common/MjolnirShortcodeEventContent.cs
diff --git a/LibMatrix/EventTypes/Common/RoomEmotesEventData.cs b/LibMatrix/EventTypes/Common/RoomEmotesEventContent.cs
index abf936c..abf936c 100644
--- a/LibMatrix/EventTypes/Common/RoomEmotesEventData.cs
+++ b/LibMatrix/EventTypes/Common/RoomEmotesEventContent.cs
diff --git a/LibMatrix/Helpers/MatrixEventAttribute.cs b/LibMatrix/EventTypes/MatrixEventAttribute.cs
index 7efc039..92334d0 100644
--- a/LibMatrix/Helpers/MatrixEventAttribute.cs
+++ b/LibMatrix/EventTypes/MatrixEventAttribute.cs
@@ -1,4 +1,4 @@
-namespace LibMatrix.Helpers;
+namespace LibMatrix.EventTypes;
 
 [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
 public class MatrixEventAttribute : Attribute {
diff --git a/LibMatrix/EventTypes/Spec/State/PresenceStateEventData.cs b/LibMatrix/EventTypes/Spec/Ephemeral/PresenceStateEventContent.cs
index b12da5b..b12da5b 100644
--- a/LibMatrix/EventTypes/Spec/State/PresenceStateEventData.cs
+++ b/LibMatrix/EventTypes/Spec/Ephemeral/PresenceStateEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/RoomTypingEventData.cs b/LibMatrix/EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs
index 01cfacf..01cfacf 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomTypingEventData.cs
+++ b/LibMatrix/EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/RoomMessageEventData.cs b/LibMatrix/EventTypes/Spec/RoomMessageEventContent.cs
index f8ee58b..f8ee58b 100644
--- a/LibMatrix/EventTypes/Spec/RoomMessageEventData.cs
+++ b/LibMatrix/EventTypes/Spec/RoomMessageEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/PolicyRuleStateEventData.cs b/LibMatrix/EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs
index fde02c1..fde02c1 100644
--- a/LibMatrix/EventTypes/Spec/State/PolicyRuleStateEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/ProfileResponseEventData.cs b/LibMatrix/EventTypes/Spec/State/ProfileResponseEventContent.cs
index 893fce1..893fce1 100644
--- a/LibMatrix/EventTypes/Spec/State/ProfileResponseEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/ProfileResponseEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/RoomAliasEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAliasEventContent.cs
index 5b0e914..5b0e914 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomAliasEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAliasEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/RoomAvatarEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAvatarEventContent.cs
index 601d014..601d014 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomAvatarEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAvatarEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/CanonicalAliasEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs
index 71f3d0d..046222e 100644
--- a/LibMatrix/EventTypes/Spec/State/CanonicalAliasEventContent.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs
@@ -5,7 +5,7 @@ using LibMatrix.Interfaces;
 namespace LibMatrix.EventTypes.Spec.State;
 
 [MatrixEvent(EventName = "m.room.canonical_alias")]
-public class CanonicalAliasEventContent : EventContent {
+public class RoomCanonicalAliasEventContent : EventContent {
     [JsonPropertyName("alias")]
     public string? Alias { get; set; }
     [JsonPropertyName("alt_aliases")]
diff --git a/LibMatrix/EventTypes/Spec/State/RoomCreateEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs
index c5bf14e..c5bf14e 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomCreateEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/RoomEncryptionEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs
index 6ffa4c5..6ffa4c5 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomEncryptionEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/GuestAccessEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs
index af1b2ce..2bb4d36 100644
--- a/LibMatrix/EventTypes/Spec/State/GuestAccessEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs
@@ -5,7 +5,7 @@ using LibMatrix.Interfaces;
 namespace LibMatrix.EventTypes.Spec.State;
 
 [MatrixEvent(EventName = "m.room.guest_access")]
-public class GuestAccessEventContent : EventContent {
+public class RoomGuestAccessEventContent : EventContent {
     [JsonPropertyName("guest_access")]
     public string GuestAccess { get; set; }
 
diff --git a/LibMatrix/EventTypes/Spec/State/HistoryVisibilityEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs
index b57ade5..a32fed2 100644
--- a/LibMatrix/EventTypes/Spec/State/HistoryVisibilityEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs
@@ -5,7 +5,7 @@ using LibMatrix.Interfaces;
 namespace LibMatrix.EventTypes.Spec.State;
 
 [MatrixEvent(EventName = "m.room.history_visibility")]
-public class HistoryVisibilityEventContent : EventContent {
+public class RoomHistoryVisibilityEventContent : EventContent {
     [JsonPropertyName("history_visibility")]
     public string HistoryVisibility { get; set; }
 }
diff --git a/LibMatrix/EventTypes/Spec/State/JoinRulesEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs
index 0098bef..2c2a91b 100644
--- a/LibMatrix/EventTypes/Spec/State/JoinRulesEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs
@@ -5,7 +5,7 @@ using LibMatrix.Interfaces;
 namespace LibMatrix.EventTypes.Spec.State;
 
 [MatrixEvent(EventName = "m.room.join_rules")]
-public class JoinRulesEventContent : EventContent {
+public class RoomJoinRulesEventContent : EventContent {
     private static string Public = "public";
     private static string Invite = "invite";
     private static string Knock = "knock";
diff --git a/LibMatrix/EventTypes/Spec/State/RoomMemberEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs
index da158f1..52cb293 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomMemberEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs
@@ -13,7 +13,7 @@ public class RoomMemberEventContent : EventContent {
     public string Membership { get; set; } = null!;
 
     [JsonPropertyName("displayname")]
-    public string? Displayname { get; set; }
+    public string? DisplayName { get; set; }
 
     [JsonPropertyName("is_direct")]
     public bool? IsDirect { get; set; }
diff --git a/LibMatrix/EventTypes/Spec/State/RoomNameEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs
index 7cb881a..7cb881a 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomNameEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/RoomPinnedEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs
index eb02cc7..eb02cc7 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomPinnedEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/RoomPowerLevelEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs
index 2ae9593..2ae9593 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomPowerLevelEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/ServerACLEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs
index f18fe43..5c5627c 100644
--- a/LibMatrix/EventTypes/Spec/State/ServerACLEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs
@@ -5,7 +5,7 @@ using LibMatrix.Interfaces;
 namespace LibMatrix.EventTypes.Spec.State;
 
 [MatrixEvent(EventName = "m.room.server_acl")]
-public class ServerACLEventContent : EventContent {
+public class RoomServerACLEventContent : EventContent {
     [JsonPropertyName("allow")]
     public List<string> Allow { get; set; } // = null!;
 
diff --git a/LibMatrix/EventTypes/Spec/State/RoomTopicEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs
index 52c7e42..52c7e42 100644
--- a/LibMatrix/EventTypes/Spec/State/RoomTopicEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/SpaceChildEventData.cs b/LibMatrix/EventTypes/Spec/State/Space/SpaceChildEventContent.cs
index 0a897dc..0a897dc 100644
--- a/LibMatrix/EventTypes/Spec/State/SpaceChildEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/Space/SpaceChildEventContent.cs
diff --git a/LibMatrix/EventTypes/Spec/State/SpaceParentEventData.cs b/LibMatrix/EventTypes/Spec/State/Space/SpaceParentEventContent.cs
index 0ffa193..0ffa193 100644
--- a/LibMatrix/EventTypes/Spec/State/SpaceParentEventData.cs
+++ b/LibMatrix/EventTypes/Spec/State/Space/SpaceParentEventContent.cs
diff --git a/LibMatrix/EventTypes/UnknownStateEventData.cs b/LibMatrix/EventTypes/UnknownStateEventContent.cs
index 9a276c8..9a276c8 100644
--- a/LibMatrix/EventTypes/UnknownStateEventData.cs
+++ b/LibMatrix/EventTypes/UnknownStateEventContent.cs
diff --git a/LibMatrix/Extensions/EnumerableExtensions.cs b/LibMatrix/Extensions/EnumerableExtensions.cs
new file mode 100644
index 0000000..d9619b7
--- /dev/null
+++ b/LibMatrix/Extensions/EnumerableExtensions.cs
@@ -0,0 +1,28 @@
+namespace LibMatrix.Extensions;
+
+public static class EnumerableExtensions {
+    public static void MergeStateEventLists(this List<StateEvent> oldState, List<StateEvent> newState) {
+        foreach (var stateEvent in newState) {
+            var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
+            if (old is null) {
+                oldState.Add(stateEvent);
+                continue;
+            }
+            oldState.Remove(old);
+            oldState.Add(stateEvent);
+        }
+    }
+
+    public static void MergeStateEventLists(this List<StateEventResponse> oldState, List<StateEventResponse> newState) {
+        foreach (var stateEvent in newState) {
+            var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
+            if (old is null) {
+                oldState.Add(stateEvent);
+                continue;
+            }
+            oldState.Remove(old);
+            oldState.Add(stateEvent);
+        }
+    }
+
+}
diff --git a/LibMatrix/Extensions/HttpClientExtensions.cs b/LibMatrix/Extensions/HttpClientExtensions.cs
index a5eb40f..2fe99b6 100644
--- a/LibMatrix/Extensions/HttpClientExtensions.cs
+++ b/LibMatrix/Extensions/HttpClientExtensions.cs
@@ -68,7 +68,16 @@ public class MatrixHttpClient : HttpClient {
         var response = await SendAsync(request, cancellationToken);
         response.EnsureSuccessStatusCode();
         await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
-        return await JsonSerializer.DeserializeAsync<T>(responseStream, cancellationToken: cancellationToken);
+#if DEBUG && false // This is only used for testing, so it's disabled by default
+        try {
+            await PostAsync("http://localhost:5116/validate/" + typeof(T).AssemblyQualifiedName, new StreamContent(responseStream), cancellationToken);
+        }
+        catch (Exception e) {
+            Console.WriteLine("[!!] Checking sync response failed: " + e);
+        }
+#endif
+        return await JsonSerializer.DeserializeAsync<T>(responseStream, cancellationToken: cancellationToken) ??
+               throw new InvalidOperationException("Failed to deserialize response");
     }
 
     // GetStreamAsync
@@ -80,7 +89,8 @@ public class MatrixHttpClient : HttpClient {
         return await response.Content.ReadAsStreamAsync(cancellationToken);
     }
 
-    public new async Task<HttpResponseMessage> PutAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) {
+    public new async Task<HttpResponseMessage> PutAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null,
+        CancellationToken cancellationToken = default) {
         var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
         request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
         request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType()), Encoding.UTF8, "application/json");
diff --git a/LibMatrix/Helpers/MessageFormatter.cs b/LibMatrix/Helpers/MessageFormatter.cs
index ae02afc..d252e85 100644
--- a/LibMatrix/Helpers/MessageFormatter.cs
+++ b/LibMatrix/Helpers/MessageFormatter.cs
@@ -13,8 +13,7 @@ public static class MessageFormatter {
 
     public static RoomMessageEventContent FormatException(string error, Exception e) {
         return new RoomMessageEventContent(body: $"{error}: {e.Message}", messageType: "m.text") {
-            FormattedBody = $"<font color=\"#FF0000\">{error}: <pre>{e.Message}</pre>" +
-                            $"</font>",
+            FormattedBody = $"<font color=\"#FF0000\">{error}: <pre>{e.Message}</pre></font>",
             Format = "org.matrix.custom.html"
         };
     }
@@ -36,4 +35,10 @@ public static class MessageFormatter {
     public static string HtmlFormatMention(string id, string? displayName = null) {
         return $"<a href=\"https://matrix.to/#/{id}\">{displayName ?? id}</a>";
     }
+
+#region Extension functions
+
+    public static RoomMessageEventContent ToMatrixMessage(this Exception e, string error) => FormatException(error, e);
+
+#endregion
 }
diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs
index 74972a1..06ae3fe 100644
--- a/LibMatrix/Helpers/SyncHelper.cs
+++ b/LibMatrix/Helpers/SyncHelper.cs
@@ -1,228 +1,115 @@
+using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
 using System.Net.Http.Json;
-using System.Text.Json.Serialization;
 using ArcaneLibs.Extensions;
 using LibMatrix.Filters;
 using LibMatrix.Homeservers;
 using LibMatrix.Responses;
 using LibMatrix.Services;
+using Microsoft.Extensions.Logging;
 
 namespace LibMatrix.Helpers;
 
-public class SyncHelper(AuthenticatedHomeserverGeneric homeserver) {
-    public async Task<SyncResult?> Sync(
-        string? since = null,
-        int? timeout = 30000,
-        string? setPresence = "online",
-        SyncFilter? filter = null,
-        CancellationToken? cancellationToken = null) {
-        var url = $"/_matrix/client/v3/sync?timeout={timeout}&set_presence={setPresence}";
-        if (!string.IsNullOrWhiteSpace(since)) url += $"&since={since}";
-        if (filter is not null) url += $"&filter={filter.ToJson(ignoreNull: true, indent: false)}";
-        // else url += "&full_state=true";
-        Console.WriteLine("Calling: " + url);
-        try {
-            var req = await homeserver._httpClient.GetAsync(url, cancellationToken: cancellationToken ?? CancellationToken.None);
+public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null) {
+    public string? Since { get; set; }
+    public int Timeout { get; set; } = 30000;
+    public string? SetPresence { get; set; } = "online";
+    public SyncFilter? Filter { get; set; }
+    public bool FullState { get; set; } = false;
 
-#if DEBUG && false
-            try {
-                await homeserver._httpClient.PostAsync(
-                    "http://localhost:5116/validate/" + typeof(SyncResult).AssemblyQualifiedName,
-                    new StreamContent(await req.Content.ReadAsStreamAsync()));
-            }
 
-            catch (Exception e) {
-                Console.WriteLine("[!!] Checking sync response failed: " + e);
-            }
-            var res = await req.Content.ReadFromJsonAsync<SyncResult>();
-            return res;
-#else
-            return await req.Content.ReadFromJsonAsync<SyncResult>();
-#endif
+    public async Task<SyncResponse?> SyncAsync(CancellationToken? cancellationToken = null) {
+        var url = $"/_matrix/client/v3/sync?timeout={Timeout}&set_presence={SetPresence}&full_state={(FullState ? "true" : "false")}";
+        if (!string.IsNullOrWhiteSpace(Since)) url += $"&since={Since}";
+        if (Filter is not null) url += $"&filter={Filter.ToJson(ignoreNull: true, indent: false)}";
+        // Console.WriteLine("Calling: " + url);
+        logger?.LogInformation("SyncHelper: Calling: {}", url);
+        try {
+            return await homeserver._httpClient.GetFromJsonAsync<SyncResponse>(url, cancellationToken: cancellationToken ?? CancellationToken.None);
         }
         catch (TaskCanceledException) {
             Console.WriteLine("Sync cancelled!");
+            logger?.LogWarning("Sync cancelled due to TaskCanceledException!");
         }
         catch (Exception e) {
             Console.WriteLine(e);
+            logger?.LogError(e, "Failed to sync!\n{}", e.ToString());
         }
 
         return null;
     }
 
-    [SuppressMessage("ReSharper", "FunctionNeverReturns")]
-    public async Task RunSyncLoop(
-        bool skipInitialSyncEvents = true,
-        string? since = null,
-        int? timeout = 30000,
-        string? setPresence = "online",
-        SyncFilter? filter = null,
-        CancellationToken? cancellationToken = null
-    ) {
-        // await Task.WhenAll((await storageService.CacheStorageProvider.GetAllKeysAsync())
-        //     .Where(x => x.StartsWith("sync"))
-        //     .ToList()
-        //     .Select(x => storageService.CacheStorageProvider.DeleteObjectAsync(x)));
-        var nextBatch = since;
-        while (cancellationToken is null || !cancellationToken.Value.IsCancellationRequested) {
-            var sync = await Sync(since: nextBatch, timeout: timeout, setPresence: setPresence, filter: filter,
-                cancellationToken: cancellationToken);
-            nextBatch = sync?.NextBatch ?? nextBatch;
+    public async IAsyncEnumerable<SyncResponse> EnumerateSyncAsync(CancellationToken? cancellationToken = null) {
+        while(!cancellationToken?.IsCancellationRequested ?? true) {
+            var sync = await SyncAsync(cancellationToken);
             if (sync is null) continue;
-            Console.WriteLine($"Got sync, next batch: {nextBatch}!");
-
-            if (sync.Rooms is { Invite.Count: > 0 }) {
-                foreach (var roomInvite in sync.Rooms.Invite) {
-                    var tasks = InviteReceivedHandlers.Select(x => x(roomInvite)).ToList();
-                    await Task.WhenAll(tasks);
-                }
-            }
-
-            if (sync.AccountData is { Events: { Count: > 0 } }) {
-                foreach (var accountDataEvent in sync.AccountData.Events) {
-                    var tasks = AccountDataReceivedHandlers.Select(x => x(accountDataEvent)).ToList();
-                    await Task.WhenAll(tasks);
-                }
-            }
-
-            // Things that are skipped on the first sync
-            if (skipInitialSyncEvents) {
-                skipInitialSyncEvents = false;
-                continue;
-            }
-
-            if (sync.Rooms is { Join.Count: > 0 }) {
-                foreach (var updatedRoom in sync.Rooms.Join) {
-                    if(updatedRoom.Value.Timeline is null) continue;
-                    foreach (var stateEventResponse in updatedRoom.Value.Timeline.Events) {
-                        stateEventResponse.RoomId = updatedRoom.Key;
-                        var tasks = TimelineEventHandlers.Select(x => {
-                            try {
-                                return x(stateEventResponse);
-                            }
-                            catch (Exception e) {
-                                Console.WriteLine(e);
-                                return Task.CompletedTask;
-                            }
-                        }).ToList();
-                        await Task.WhenAll(tasks);
-                    }
-                }
-            }
+            Since = sync.NextBatch ?? Since;
+            yield return sync;
         }
     }
 
-    /// <summary>
-    /// Event fired when a room invite is received
-    /// </summary>
-    public List<Func<KeyValuePair<string, SyncResult.RoomsDataStructure.InvitedRoomDataStructure>, Task>>
-        InviteReceivedHandlers { get; } = new();
-
-    public List<Func<StateEventResponse, Task>> TimelineEventHandlers { get; } = new();
-    public List<Func<StateEventResponse, Task>> AccountDataReceivedHandlers { get; } = new();
-}
-
-public class SyncResult {
-    [JsonPropertyName("next_batch")]
-    public string NextBatch { get; set; }
-
-    [JsonPropertyName("account_data")]
-    public EventList? AccountData { get; set; }
-
-    [JsonPropertyName("presence")]
-    public PresenceDataStructure? Presence { get; set; }
-
-    [JsonPropertyName("device_one_time_keys_count")]
-    public Dictionary<string, int> DeviceOneTimeKeysCount { get; set; }
-
-    [JsonPropertyName("rooms")]
-    public RoomsDataStructure? Rooms { get; set; }
-
-    [JsonPropertyName("to_device")]
-    public EventList? ToDevice { get; set; }
-
-    [JsonPropertyName("device_lists")]
-    public DeviceListsDataStructure? DeviceLists { get; set; }
-
-    public class DeviceListsDataStructure {
-        [JsonPropertyName("changed")]
-        public List<string>? Changed { get; set; }
-
-        [JsonPropertyName("left")]
-        public List<string>? Left { get; set; }
-    }
-
-    // supporting classes
-    public class PresenceDataStructure {
-        [JsonPropertyName("events")]
-        public List<StateEventResponse> Events { get; set; }
+    public async Task RunSyncLoopAsync(bool skipInitialSyncEvents = true, CancellationToken? cancellationToken = null) {
+        var sw = Stopwatch.StartNew();
+        await foreach (var sync in EnumerateSyncAsync(cancellationToken)) {
+            logger?.LogInformation("Got sync response: {} bytes, {} elapsed", sync?.ToJson(ignoreNull: true, indent: false).Length ?? -1, sw.Elapsed);
+            await RunSyncLoopCallbacksAsync(sync, Since is null && skipInitialSyncEvents);
+        }
     }
 
-    public class RoomsDataStructure {
-        [JsonPropertyName("join")]
-        public Dictionary<string, JoinedRoomDataStructure>? Join { get; set; }
-
-        [JsonPropertyName("invite")]
-        public Dictionary<string, InvitedRoomDataStructure>? Invite { get; set; }
-
-        public class JoinedRoomDataStructure {
-            [JsonPropertyName("timeline")]
-            public TimelineDataStructure? Timeline { get; set; }
-
-            [JsonPropertyName("state")]
-            public EventList State { get; set; }
-
-            [JsonPropertyName("account_data")]
-            public EventList AccountData { get; set; }
-
-            [JsonPropertyName("ephemeral")]
-            public EventList Ephemeral { get; set; }
-
-            [JsonPropertyName("unread_notifications")]
-            public UnreadNotificationsDataStructure UnreadNotifications { get; set; }
-
-            [JsonPropertyName("summary")]
-            public SummaryDataStructure Summary { get; set; }
+    private async Task RunSyncLoopCallbacksAsync(SyncResponse syncResponse, bool isInitialSync) {
 
-            public class TimelineDataStructure {
-                [JsonPropertyName("events")]
-                public List<StateEventResponse> Events { get; set; }
+        var tasks = SyncReceivedHandlers.Select(x => x(syncResponse)).ToList();
+        await Task.WhenAll(tasks);
 
-                [JsonPropertyName("prev_batch")]
-                public string PrevBatch { get; set; }
-
-                [JsonPropertyName("limited")]
-                public bool Limited { get; set; }
+        if (syncResponse.AccountData is { Events: { Count: > 0 } }) {
+            foreach (var accountDataEvent in syncResponse.AccountData.Events) {
+                tasks = AccountDataReceivedHandlers.Select(x => x(accountDataEvent)).ToList();
+                await Task.WhenAll(tasks);
             }
+        }
 
-            public class UnreadNotificationsDataStructure {
-                [JsonPropertyName("notification_count")]
-                public int NotificationCount { get; set; }
+        await RunSyncLoopRoomCallbacksAsync(syncResponse, isInitialSync);
+    }
 
-                [JsonPropertyName("highlight_count")]
-                public int HighlightCount { get; set; }
+    private async Task RunSyncLoopRoomCallbacksAsync(SyncResponse syncResponse, bool isInitialSync) {
+        if (syncResponse.Rooms is { Invite.Count: > 0 }) {
+            foreach (var roomInvite in syncResponse.Rooms.Invite) {
+                var tasks = InviteReceivedHandlers.Select(x => x(roomInvite)).ToList();
+                await Task.WhenAll(tasks);
             }
+        }
 
-            public class SummaryDataStructure {
-                [JsonPropertyName("m.heroes")]
-                public List<string> Heroes { get; set; }
-
-                [JsonPropertyName("m.invited_member_count")]
-                public int InvitedMemberCount { get; set; }
+        if (isInitialSync) return;
 
-                [JsonPropertyName("m.joined_member_count")]
-                public int JoinedMemberCount { get; set; }
+        if (syncResponse.Rooms is { Join.Count: > 0 }) {
+            foreach (var updatedRoom in syncResponse.Rooms.Join) {
+                if (updatedRoom.Value.Timeline is null) continue;
+                foreach (var stateEventResponse in updatedRoom.Value.Timeline.Events) {
+                    stateEventResponse.RoomId = updatedRoom.Key;
+                    var tasks = TimelineEventHandlers.Select(x => x(stateEventResponse)).ToList();
+                    await Task.WhenAll(tasks);
+                }
             }
         }
-
-        public class InvitedRoomDataStructure {
-            [JsonPropertyName("invite_state")]
-            public EventList InviteState { get; set; }
-        }
     }
-}
 
-public class EventList {
-    [JsonPropertyName("events")]
-    public List<StateEventResponse> Events { get; set; }
+    /// <summary>
+    /// Event fired when a sync response is received
+    /// </summary>
+    public List<Func<SyncResponse, Task>> SyncReceivedHandlers { get; } = new();
+
+    /// <summary>
+    /// Event fired when a room invite is received
+    /// </summary>
+    public List<Func<KeyValuePair<string, SyncResponse.RoomsDataStructure.InvitedRoomDataStructure>, Task>> InviteReceivedHandlers { get; } = new();
+
+    /// <summary>
+    /// Event fired when a timeline event is received
+    /// </summary>
+    public List<Func<StateEventResponse, Task>> TimelineEventHandlers { get; } = new();
+
+    /// <summary>
+    /// Event fired when an account data event is received
+    /// </summary>
+    public List<Func<StateEventResponse, Task>> AccountDataReceivedHandlers { get; } = new();
 }
diff --git a/LibMatrix/Helpers/SyncStateResolver.cs b/LibMatrix/Helpers/SyncStateResolver.cs
new file mode 100644
index 0000000..0070d60
--- /dev/null
+++ b/LibMatrix/Helpers/SyncStateResolver.cs
@@ -0,0 +1,174 @@
+using LibMatrix.Extensions;
+using LibMatrix.Filters;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using Microsoft.Extensions.Logging;
+
+namespace LibMatrix.Helpers;
+
+public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null) {
+    public string? Since { get; set; }
+    public int Timeout { get; set; } = 30000;
+    public string? SetPresence { get; set; } = "online";
+    public SyncFilter? Filter { get; set; }
+    public bool FullState { get; set; } = false;
+
+    public SyncResponse? MergedState { get; set; } = null!;
+
+    private SyncHelper _syncHelper = new SyncHelper(homeserver, logger);
+
+    public async Task<(SyncResponse next, SyncResponse merged)> ContinueAsync(CancellationToken? cancellationToken = null) {
+        // copy properties
+        _syncHelper.Since = Since;
+        _syncHelper.Timeout = Timeout;
+        _syncHelper.SetPresence = SetPresence;
+        _syncHelper.Filter = Filter;
+        _syncHelper.FullState = FullState;
+        // run sync
+        var sync = await _syncHelper.SyncAsync(cancellationToken);
+        if (sync is null) return await ContinueAsync(cancellationToken);
+        if (MergedState is null) MergedState = sync;
+        else MergedState = MergeSyncs(MergedState, sync);
+        Since = sync.NextBatch;
+        return (sync, MergedState);
+    }
+
+    private SyncResponse MergeSyncs(SyncResponse oldState, SyncResponse newState) {
+        oldState.NextBatch = newState.NextBatch ?? oldState.NextBatch;
+
+        oldState.AccountData ??= new();
+        oldState.AccountData.Events ??= new();
+        if (newState.AccountData?.Events is not null)
+            oldState.AccountData.Events.MergeStateEventLists(newState.AccountData?.Events ?? new());
+
+        oldState.Presence ??= new();
+        if (newState.Presence?.Events is not null)
+            oldState.Presence.Events.MergeStateEventLists(newState.Presence?.Events ?? new());
+
+        oldState.DeviceOneTimeKeysCount ??= new();
+        if (newState.DeviceOneTimeKeysCount is not null)
+            foreach (var (key, value) in newState.DeviceOneTimeKeysCount) {
+                oldState.DeviceOneTimeKeysCount[key] = value;
+            }
+
+        oldState.Rooms ??= new();
+        if (newState.Rooms is not null)
+            oldState.Rooms = MergeRoomsDataStructure(oldState.Rooms, newState.Rooms);
+
+        oldState.ToDevice ??= new();
+        oldState.ToDevice.Events ??= new();
+        if (newState.ToDevice?.Events is not null)
+            oldState.ToDevice.Events.MergeStateEventLists(newState.ToDevice?.Events ?? new());
+
+        oldState.DeviceLists ??= new();
+        if (newState.DeviceLists?.Changed is not null)
+            foreach (var s in oldState.DeviceLists.Changed!) {
+                oldState.DeviceLists.Changed.Add(s);
+            }
+        if (newState.DeviceLists?.Left is not null)
+            foreach (var s in oldState.DeviceLists.Left!) {
+                oldState.DeviceLists.Left.Add(s);
+            }
+
+
+        return oldState;
+    }
+
+#region Merge rooms
+
+    private SyncResponse.RoomsDataStructure MergeRoomsDataStructure(SyncResponse.RoomsDataStructure oldState, SyncResponse.RoomsDataStructure newState) {
+        oldState.Join ??= new();
+        foreach (var (key, value) in newState.Join ?? new()) {
+            if (!oldState.Join.ContainsKey(key)) oldState.Join[key] = value;
+            else oldState.Join[key] = MergeJoinedRoomDataStructure(oldState.Join[key], value);
+        }
+
+        oldState.Invite ??= new();
+        foreach (var (key, value) in newState.Invite ?? new()) {
+            if (!oldState.Invite.ContainsKey(key)) oldState.Invite[key] = value;
+            else oldState.Invite[key] = MergeInvitedRoomDataStructure(oldState.Invite[key], value);
+        }
+
+        oldState.Leave ??= new();
+        foreach (var (key, value) in newState.Leave ?? new()) {
+            if (!oldState.Leave.ContainsKey(key)) oldState.Leave[key] = value;
+            else oldState.Leave[key] = MergeLeftRoomDataStructure(oldState.Leave[key], value);
+            if (oldState.Invite.ContainsKey(key)) oldState.Invite.Remove(key);
+            if (oldState.Join.ContainsKey(key)) oldState.Join.Remove(key);
+        }
+
+        return oldState;
+    }
+
+    private SyncResponse.RoomsDataStructure.LeftRoomDataStructure MergeLeftRoomDataStructure(SyncResponse.RoomsDataStructure.LeftRoomDataStructure oldData,
+        SyncResponse.RoomsDataStructure.LeftRoomDataStructure newData) {
+        oldData.AccountData ??= new();
+        oldData.AccountData.Events ??= new();
+        oldData.Timeline ??= new();
+        oldData.Timeline.Events ??= new();
+        oldData.State ??= new();
+        oldData.State.Events ??= new();
+
+        if (newData.AccountData?.Events is not null)
+            oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? new());
+
+        if (newData.Timeline?.Events is not null)
+            oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? new());
+        oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited;
+        oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch;
+
+        if (newData.State?.Events is not null)
+            oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? new());
+
+        return oldData;
+    }
+
+    private SyncResponse.RoomsDataStructure.InvitedRoomDataStructure MergeInvitedRoomDataStructure(SyncResponse.RoomsDataStructure.InvitedRoomDataStructure oldData,
+        SyncResponse.RoomsDataStructure.InvitedRoomDataStructure newData) {
+        oldData.InviteState ??= new();
+        oldData.InviteState.Events ??= new();
+        if (newData.InviteState?.Events is not null)
+            oldData.InviteState.Events.MergeStateEventLists(newData.InviteState?.Events ?? new());
+
+        return oldData;
+    }
+
+    private SyncResponse.RoomsDataStructure.JoinedRoomDataStructure MergeJoinedRoomDataStructure(SyncResponse.RoomsDataStructure.JoinedRoomDataStructure oldData,
+        SyncResponse.RoomsDataStructure.JoinedRoomDataStructure newData) {
+        oldData.AccountData ??= new();
+        oldData.AccountData.Events ??= new();
+        oldData.Timeline ??= new();
+        oldData.Timeline.Events ??= new();
+        oldData.State ??= new();
+        oldData.State.Events ??= new();
+        oldData.Ephemeral ??= new();
+        oldData.Ephemeral.Events ??= new();
+
+        if (newData.AccountData?.Events is not null)
+            oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? new());
+
+        if (newData.Timeline?.Events is not null)
+            oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? new());
+        oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited;
+        oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch;
+
+        if (newData.State?.Events is not null)
+            oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? new());
+
+        if (newData.Ephemeral?.Events is not null)
+            oldData.Ephemeral.Events.MergeStateEventLists(newData.Ephemeral?.Events ?? new());
+
+        oldData.UnreadNotifications ??= new();
+        oldData.UnreadNotifications.HighlightCount = newData.UnreadNotifications?.HighlightCount ?? oldData.UnreadNotifications.HighlightCount;
+        oldData.UnreadNotifications.NotificationCount = newData.UnreadNotifications?.NotificationCount ?? oldData.UnreadNotifications.NotificationCount;
+
+        oldData.Summary ??= new();
+        oldData.Summary.Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes;
+        oldData.Summary.JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount;
+        oldData.Summary.InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount;
+
+        return oldData;
+    }
+
+#endregion
+}
diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
index f70dd39..d5b0a77 100644
--- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
+++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
@@ -12,19 +12,35 @@ using LibMatrix.Services;
 
 namespace LibMatrix.Homeservers;
 
-public class AuthenticatedHomeserverGeneric : RemoteHomeServer {
-    public AuthenticatedHomeserverGeneric(string baseUrl, string accessToken) : base(baseUrl) {
-        AccessToken = accessToken.Trim();
-        SyncHelper = new SyncHelper(this);
-
-        _httpClient.Timeout = TimeSpan.FromMinutes(15);
-        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+public class AuthenticatedHomeserverGeneric(string baseUrl, string accessToken) : RemoteHomeServer(baseUrl) {
+    public static async Task<T> Create<T>(string baseUrl, string accessToken) where T : AuthenticatedHomeserverGeneric {
+        var instance = Activator.CreateInstance(typeof(T), baseUrl, accessToken) as T
+                       ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}");
+        instance._httpClient = new() {
+            BaseAddress = new Uri(await new HomeserverResolverService().ResolveHomeserverFromWellKnown(baseUrl)
+                                  ?? throw new InvalidOperationException("Failed to resolve homeserver")),
+            Timeout = TimeSpan.FromMinutes(15),
+            DefaultRequestHeaders = {
+                Authorization = new AuthenticationHeaderValue("Bearer", accessToken)
+            }
+        };
+        instance.WhoAmI = await instance._httpClient.GetFromJsonAsync<WhoAmIResponse>("/_matrix/client/v3/account/whoami");
+        return instance;
     }
 
-    public virtual SyncHelper SyncHelper { get; init; }
-    private WhoAmIResponse? _whoAmI;
+    // Activator.CreateInstance(baseUrl, accessToken) {
+        //     _httpClient = new() {
+        //         BaseAddress = new Uri(await new HomeserverResolverService().ResolveHomeserverFromWellKnown(baseUrl)
+        //                               ?? throw new InvalidOperationException("Failed to resolve homeserver")),
+        //         Timeout = TimeSpan.FromMinutes(15),
+        //         DefaultRequestHeaders = {
+        //             Authorization = new AuthenticationHeaderValue("Bearer", accessToken)
+        //         }
+        //     }
+        // };
+
 
-    public WhoAmIResponse? WhoAmI => _whoAmI ??= _httpClient.GetFromJsonAsync<WhoAmIResponse>("/_matrix/client/v3/account/whoami").Result;
+    public WhoAmIResponse? WhoAmI { get; set; }
     public string UserId => WhoAmI.UserId;
 
     // public virtual async Task<WhoAmIResponse> WhoAmI() {
@@ -33,9 +49,9 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeServer {
     // return _whoAmI;
     // }
 
-    public virtual string AccessToken { get; set; }
+    public string AccessToken { get; set; } = accessToken;
 
-    public virtual GenericRoom GetRoom(string roomId) {
+    public GenericRoom GetRoom(string roomId) {
         if (roomId is null || !roomId.StartsWith("!")) throw new ArgumentException("Room ID must start with !", nameof(roomId));
         return new GenericRoom(this, roomId);
     }
@@ -112,7 +128,7 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeServer {
 
 #region Account Data
 
-    public virtual async Task<T> GetAccountData<T>(string key) {
+    public virtual async Task<T> GetAccountDataAsync<T>(string key) {
         // var res = await _httpClient.GetAsync($"/_matrix/client/v3/user/{UserId}/account_data/{key}");
         // if (!res.IsSuccessStatusCode) {
         //     Console.WriteLine($"Failed to get account data: {await res.Content.ReadAsStringAsync()}");
diff --git a/LibMatrix/Homeservers/RemoteHomeServer.cs b/LibMatrix/Homeservers/RemoteHomeServer.cs
index d10c837..798349a 100644
--- a/LibMatrix/Homeservers/RemoteHomeServer.cs
+++ b/LibMatrix/Homeservers/RemoteHomeServer.cs
@@ -1,3 +1,4 @@
+using System.Net.Http.Headers;
 using System.Net.Http.Json;
 using System.Text.Json;
 using System.Text.Json.Serialization;
@@ -10,13 +11,18 @@ using LibMatrix.Services;
 namespace LibMatrix.Homeservers;
 
 public class RemoteHomeServer(string baseUrl) {
+    public static async Task<RemoteHomeServer> Create(string baseUrl) =>
+        new(baseUrl) {
+            _httpClient = new() {
+                BaseAddress = new Uri(await new HomeserverResolverService().ResolveHomeserverFromWellKnown(baseUrl)
+                                      ?? throw new InvalidOperationException("Failed to resolve homeserver")),
+                Timeout = TimeSpan.FromSeconds(120)
+            }
+        };
 
     private Dictionary<string, object> _profileCache { get; set; } = new();
-    public string BaseUrl { get; } = baseUrl.Trim();
-    public MatrixHttpClient _httpClient { get; set; } = new() {
-        BaseAddress = new Uri(new HomeserverResolverService().ResolveHomeserverFromWellKnown(baseUrl).Result ?? throw new InvalidOperationException("Failed to resolve homeserver")),
-        Timeout = TimeSpan.FromSeconds(120)
-    };
+    public string BaseUrl { get; } = baseUrl;
+    public MatrixHttpClient _httpClient { get; set; }
 
     public async Task<ProfileResponseEventContent> GetProfileAsync(string mxid) {
         if (mxid is null) throw new ArgumentNullException(nameof(mxid));
diff --git a/LibMatrix/Interfaces/IStateEventType.cs b/LibMatrix/Interfaces/EventContent.cs
index b187970..b21cfc7 100644
--- a/LibMatrix/Interfaces/IStateEventType.cs
+++ b/LibMatrix/Interfaces/EventContent.cs
@@ -9,7 +9,7 @@ public abstract class EventContent {
     [JsonPropertyName("m.new_content")]
     public EventContent? NewContent { get; set; }
 
-    public abstract class MessageRelatesTo {
+    public class MessageRelatesTo {
         [JsonPropertyName("m.in_reply_to")]
         public EventInReplyTo? InReplyTo { get; set; }
 
@@ -18,6 +18,9 @@ public abstract class EventContent {
         public abstract class EventInReplyTo {
             [JsonPropertyName("event_id")]
             public string EventId { get; set; }
+
+            [JsonPropertyName("rel_type")]
+            public string RelType { get; set; }
         }
     }
 }
diff --git a/LibMatrix/Responses/CreateRoomRequest.cs b/LibMatrix/Responses/CreateRoomRequest.cs
index 511b3da..1ad590f 100644
--- a/LibMatrix/Responses/CreateRoomRequest.cs
+++ b/LibMatrix/Responses/CreateRoomRequest.cs
@@ -2,6 +2,7 @@ using System.Reflection;
 using System.Text.Json.Nodes;
 using System.Text.Json.Serialization;
 using System.Text.RegularExpressions;
+using LibMatrix.EventTypes;
 using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.Helpers;
 using LibMatrix.Homeservers;
diff --git a/LibMatrix/Responses/LoginResponse.cs b/LibMatrix/Responses/LoginResponse.cs
index eb53c0a..07b1601 100644
--- a/LibMatrix/Responses/LoginResponse.cs
+++ b/LibMatrix/Responses/LoginResponse.cs
@@ -23,7 +23,7 @@ public class LoginResponse {
     public string UserId { get; set; } = null!;
 
     public async Task<AuthenticatedHomeserverGeneric> GetAuthenticatedHomeserver(string? proxy = null) {
-        return new AuthenticatedHomeserverGeneric(proxy ?? await new HomeserverResolverService().ResolveHomeserverFromWellKnown(Homeserver), AccessToken);
+        return await AuthenticatedHomeserverGeneric.Create<AuthenticatedHomeserverGeneric>(proxy ?? await new HomeserverResolverService().ResolveHomeserverFromWellKnown(Homeserver), AccessToken);
     }
 }
 public class LoginRequest {
diff --git a/LibMatrix/Responses/StateEventResponse.cs b/LibMatrix/Responses/StateEventResponse.cs
deleted file mode 100644
index 7ca6bab..0000000
--- a/LibMatrix/Responses/StateEventResponse.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System.Text.Json.Nodes;
-using System.Text.Json.Serialization;
-
-namespace LibMatrix.Responses;
-
-public class StateEventResponse : StateEvent {
-    [JsonPropertyName("origin_server_ts")]
-    public ulong OriginServerTs { get; set; }
-
-    [JsonPropertyName("room_id")]
-    public string RoomId { get; set; }
-
-    [JsonPropertyName("sender")]
-    public string Sender { get; set; }
-
-    [JsonPropertyName("unsigned")]
-    public UnsignedData? Unsigned { get; set; }
-
-    [JsonPropertyName("event_id")]
-    public string EventId { get; set; }
-
-    [JsonPropertyName("user_id")]
-    public string UserId { get; set; }
-
-    [JsonPropertyName("replaces_state")]
-    public new string ReplacesState { get; set; }
-
-    public class UnsignedData {
-        [JsonPropertyName("age")]
-        public ulong? Age { get; set; }
-
-        [JsonPropertyName("redacted_because")]
-        public object? RedactedBecause { get; set; }
-
-        [JsonPropertyName("transaction_id")]
-        public string? TransactionId { get; set; }
-
-        [JsonPropertyName("replaces_state")]
-        public string? ReplacesState { get; set; }
-
-        [JsonPropertyName("prev_sender")]
-        public string? PrevSender { get; set; }
-
-        [JsonPropertyName("prev_content")]
-        public JsonObject? PrevContent { get; set; }
-    }
-}
-
-public class ChunkedStateEventResponse {
-    [JsonPropertyName("chunk")]
-    public List<StateEventResponse>? Chunk { get; set; }
-}
diff --git a/LibMatrix/Responses/SyncResponse.cs b/LibMatrix/Responses/SyncResponse.cs
new file mode 100644
index 0000000..39cb38f
--- /dev/null
+++ b/LibMatrix/Responses/SyncResponse.cs
@@ -0,0 +1,118 @@
+using System.Text.Json.Serialization;
+using LibMatrix.Helpers;
+
+namespace LibMatrix.Responses;
+
+public class SyncResponse {
+    [JsonPropertyName("next_batch")]
+    public string NextBatch { get; set; } = null!;
+
+    [JsonPropertyName("account_data")]
+    public EventList? AccountData { get; set; }
+
+    [JsonPropertyName("presence")]
+    public PresenceDataStructure? Presence { get; set; }
+
+    [JsonPropertyName("device_one_time_keys_count")]
+    public Dictionary<string, int>? DeviceOneTimeKeysCount { get; set; } = null!;
+
+    [JsonPropertyName("rooms")]
+    public RoomsDataStructure? Rooms { get; set; }
+
+    [JsonPropertyName("to_device")]
+    public EventList? ToDevice { get; set; }
+
+    [JsonPropertyName("device_lists")]
+    public DeviceListsDataStructure? DeviceLists { get; set; }
+
+    public class DeviceListsDataStructure {
+        [JsonPropertyName("changed")]
+        public List<string>? Changed { get; set; }
+
+        [JsonPropertyName("left")]
+        public List<string>? Left { get; set; }
+    }
+
+    // supporting classes
+    public class PresenceDataStructure {
+        [JsonPropertyName("events")]
+        public List<StateEventResponse> Events { get; set; } = new();
+    }
+
+    public class RoomsDataStructure {
+        [JsonPropertyName("join")]
+        public Dictionary<string, JoinedRoomDataStructure>? Join { get; set; }
+
+        [JsonPropertyName("invite")]
+        public Dictionary<string, InvitedRoomDataStructure>? Invite { get; set; }
+
+        [JsonPropertyName("leave")]
+        public Dictionary<string, LeftRoomDataStructure>? Leave { get; set; }
+
+        public class LeftRoomDataStructure {
+            [JsonPropertyName("account_data")]
+            public EventList AccountData { get; set; }
+
+            [JsonPropertyName("timeline")]
+            public JoinedRoomDataStructure.TimelineDataStructure? Timeline { get; set; }
+
+            [JsonPropertyName("state")]
+            public EventList State { get; set; }
+        }
+
+        public class JoinedRoomDataStructure {
+            [JsonPropertyName("timeline")]
+            public TimelineDataStructure? Timeline { get; set; }
+
+            [JsonPropertyName("state")]
+            public EventList? State { get; set; }
+
+            [JsonPropertyName("account_data")]
+            public EventList? AccountData { get; set; }
+
+            [JsonPropertyName("ephemeral")]
+            public EventList? Ephemeral { get; set; }
+
+            [JsonPropertyName("unread_notifications")]
+            public UnreadNotificationsDataStructure? UnreadNotifications { get; set; }
+
+            [JsonPropertyName("summary")]
+            public SummaryDataStructure? Summary { get; set; }
+
+            public class TimelineDataStructure {
+                [JsonPropertyName("events")]
+                public List<StateEventResponse>? Events { get; set; }
+
+                [JsonPropertyName("prev_batch")]
+                public string? PrevBatch { get; set; }
+
+                [JsonPropertyName("limited")]
+                public bool? Limited { get; set; }
+            }
+
+            public class UnreadNotificationsDataStructure {
+                [JsonPropertyName("notification_count")]
+                public int NotificationCount { get; set; }
+
+                [JsonPropertyName("highlight_count")]
+                public int HighlightCount { get; set; }
+            }
+
+            public class SummaryDataStructure {
+                [JsonPropertyName("m.heroes")]
+                public List<string> Heroes { get; set; }
+
+                [JsonPropertyName("m.invited_member_count")]
+                public int InvitedMemberCount { get; set; }
+
+                [JsonPropertyName("m.joined_member_count")]
+                public int JoinedMemberCount { get; set; }
+            }
+        }
+
+        public class InvitedRoomDataStructure {
+            [JsonPropertyName("invite_state")]
+            public EventList? InviteState { get; set; }
+        }
+    }
+}
diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs
index 78a0873..75cb5f3 100644
--- a/LibMatrix/RoomTypes/GenericRoom.cs
+++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -118,16 +118,16 @@ public class GenericRoom {
 
 #region Utility shortcuts
 
-    public async Task<EventIdResponse> SendMessageEventAsync(RoomMessageEventContent content) =>
+    public async Task<EventIdResponse?> SendMessageEventAsync(RoomMessageEventContent content) =>
         await SendTimelineEventAsync("m.room.message", content);
 
-    public async Task<List<string>> GetAliasesAsync() {
+    public async Task<List<string>?> GetAliasesAsync() {
         var res = await GetStateAsync<RoomAliasEventContent>("m.room.aliases");
         return res.Aliases;
     }
 
-    public async Task<CanonicalAliasEventContent?> GetCanonicalAliasAsync() =>
-        await GetStateAsync<CanonicalAliasEventContent>("m.room.canonical_alias");
+    public async Task<RoomCanonicalAliasEventContent?> GetCanonicalAliasAsync() =>
+        await GetStateAsync<RoomCanonicalAliasEventContent>("m.room.canonical_alias");
 
     public async Task<RoomTopicEventContent?> GetTopicAsync() =>
         await GetStateAsync<RoomTopicEventContent>("m.room.topic");
@@ -135,16 +135,16 @@ public class GenericRoom {
     public async Task<RoomAvatarEventContent?> GetAvatarUrlAsync() =>
         await GetStateAsync<RoomAvatarEventContent>("m.room.avatar");
 
-    public async Task<JoinRulesEventContent> GetJoinRuleAsync() =>
-        await GetStateAsync<JoinRulesEventContent>("m.room.join_rules");
+    public async Task<RoomJoinRulesEventContent?> GetJoinRuleAsync() =>
+        await GetStateAsync<RoomJoinRulesEventContent>("m.room.join_rules");
 
-    public async Task<HistoryVisibilityEventContent?> GetHistoryVisibilityAsync() =>
-        await GetStateAsync<HistoryVisibilityEventContent>("m.room.history_visibility");
+    public async Task<RoomHistoryVisibilityEventContent?> GetHistoryVisibilityAsync() =>
+        await GetStateAsync<RoomHistoryVisibilityEventContent?>("m.room.history_visibility");
 
-    public async Task<GuestAccessEventContent?> GetGuestAccessAsync() =>
-        await GetStateAsync<GuestAccessEventContent>("m.room.guest_access");
+    public async Task<RoomGuestAccessEventContent?> GetGuestAccessAsync() =>
+        await GetStateAsync<RoomGuestAccessEventContent>("m.room.guest_access");
 
-    public async Task<RoomCreateEventContent> GetCreateEventAsync() =>
+    public async Task<RoomCreateEventContent?> GetCreateEventAsync() =>
         await GetStateAsync<RoomCreateEventContent>("m.room.create");
 
     public async Task<string?> GetRoomType() {
@@ -177,24 +177,23 @@ public class GenericRoom {
         await _httpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/unban",
             new UserIdAndReason { UserId = userId });
 
-    public async Task<EventIdResponse> SendStateEventAsync(string eventType, object content) =>
+    public async Task<EventIdResponse?> SendStateEventAsync(string eventType, object content) =>
         await (await _httpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType}", content))
             .Content.ReadFromJsonAsync<EventIdResponse>();
 
-    public async Task<EventIdResponse> SendStateEventAsync(string eventType, string stateKey, object content) =>
+    public async Task<EventIdResponse?> SendStateEventAsync(string eventType, string stateKey, object content) =>
         await (await _httpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType}/{stateKey}", content))
             .Content.ReadFromJsonAsync<EventIdResponse>();
 
-    public async Task<EventIdResponse> SendTimelineEventAsync(string eventType, EventContent content) {
+    public async Task<EventIdResponse?> SendTimelineEventAsync(string eventType, EventContent content) {
         var res = await _httpClient.PutAsJsonAsync(
             $"/_matrix/client/v3/rooms/{RoomId}/send/{eventType}/" + Guid.NewGuid(), content, new JsonSerializerOptions {
                 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
             });
-        var resu = await res.Content.ReadFromJsonAsync<EventIdResponse>();
-        return resu;
+        return await res.Content.ReadFromJsonAsync<EventIdResponse>();
     }
 
-    public async Task<EventIdResponse> SendFileAsync(string fileName, Stream fileStream, string messageType = "m.file") {
+    public async Task<EventIdResponse?> SendFileAsync(string fileName, Stream fileStream, string messageType = "m.file") {
         var url = await Homeserver.UploadFile(fileName, fileStream);
         var content = new RoomMessageEventContent() {
             MessageType = messageType,
@@ -205,7 +204,7 @@ public class GenericRoom {
         return await SendTimelineEventAsync("m.room.message", content);
     }
 
-    public async Task<T> GetRoomAccountDataAsync<T>(string key) {
+    public async Task<T?> GetRoomAccountDataAsync<T>(string key) {
         var res = await _httpClient.GetAsync($"/_matrix/client/v3/user/{Homeserver.UserId}/rooms/{RoomId}/account_data/{key}");
         if (!res.IsSuccessStatusCode) {
             Console.WriteLine($"Failed to get room account data: {await res.Content.ReadAsStringAsync()}");
diff --git a/LibMatrix/Services/HomeserverProviderService.cs b/LibMatrix/Services/HomeserverProviderService.cs
index 666d2a2..1f3bd37 100644
--- a/LibMatrix/Services/HomeserverProviderService.cs
+++ b/LibMatrix/Services/HomeserverProviderService.cs
@@ -35,20 +35,15 @@ public class HomeserverProviderService {
         }
 
         var domain = proxy ?? await _homeserverResolverService.ResolveHomeserverFromWellKnown(homeserver);
-        var hc = new MatrixHttpClient { BaseAddress = new Uri(domain) };
 
         AuthenticatedHomeserverGeneric hs;
         if (true) {
-            hs = new AuthenticatedHomeserverMxApiExtended(homeserver, accessToken);
+            hs = await AuthenticatedHomeserverGeneric.Create<AuthenticatedHomeserverMxApiExtended>(homeserver, accessToken);
         }
         else {
-            hs = new AuthenticatedHomeserverGeneric(homeserver, accessToken);
+            hs = await AuthenticatedHomeserverGeneric.Create<AuthenticatedHomeserverSynapse>(homeserver, accessToken);
         }
 
-        hs._httpClient = hc;
-        hs._httpClient.Timeout = TimeSpan.FromMinutes(15);
-        hs._httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
-
         // (() => hs.WhoAmI) = (await hs._httpClient.GetFromJsonAsync<WhoAmIResponse>("/_matrix/client/v3/account/whoami"))!;
 
         lock(_authenticatedHomeServerCache)
@@ -59,7 +54,7 @@ public class HomeserverProviderService {
     }
 
     public async Task<RemoteHomeServer> GetRemoteHomeserver(string homeserver, string? proxy = null) {
-        var hs = new RemoteHomeServer(proxy ?? await _homeserverResolverService.ResolveHomeserverFromWellKnown(homeserver));
+        var hs = await RemoteHomeServer.Create(proxy ?? await _homeserverResolverService.ResolveHomeserverFromWellKnown(homeserver));
         // hs._httpClient.Dispose();
         // hs._httpClient = new MatrixHttpClient { BaseAddress = new Uri(hs.FullHomeServerDomain) };
         // hs._httpClient.Timeout = TimeSpan.FromSeconds(120);
diff --git a/LibMatrix/Services/HomeserverResolverService.cs b/LibMatrix/Services/HomeserverResolverService.cs
index 685724b..75545db 100644
--- a/LibMatrix/Services/HomeserverResolverService.cs
+++ b/LibMatrix/Services/HomeserverResolverService.cs
@@ -12,6 +12,9 @@ public class HomeserverResolverService(ILogger<HomeserverResolverService>? logge
     private static readonly Dictionary<string, SemaphoreSlim> _wellKnownSemaphores = new();
 
     public async Task<string> ResolveHomeserverFromWellKnown(string homeserver) {
+        if (homeserver is null) throw new ArgumentNullException(nameof(homeserver));
+        if(_wellKnownCache.TryGetValue(homeserver, out var known)) return known;
+        logger?.LogInformation("Resolving homeserver: {}", homeserver);
         var res = await _resolveHomeserverFromWellKnown(homeserver);
         if (!res.StartsWith("http")) res = "https://" + res;
         if (res.EndsWith(":443")) res = res[..^4];
@@ -21,6 +24,7 @@ public class HomeserverResolverService(ILogger<HomeserverResolverService>? logge
     private async Task<string> _resolveHomeserverFromWellKnown(string homeserver) {
         if (homeserver is null) throw new ArgumentNullException(nameof(homeserver));
         var sem = _wellKnownSemaphores.GetOrCreate(homeserver, _ => new SemaphoreSlim(1, 1));
+        if(_wellKnownCache.TryGetValue(homeserver, out var wellKnown)) return wellKnown;
         await sem.WaitAsync();
         if (_wellKnownCache.TryGetValue(homeserver, out var known)) {
             sem.Release();
diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs
index b42bd64..c51fadb 100644
--- a/LibMatrix/StateEvent.cs
+++ b/LibMatrix/StateEvent.cs
@@ -39,8 +39,11 @@ public class StateEvent {
 
     public EventContent TypedContent {
         get {
+            if(Type == "m.receipt") {
+                return null!;
+            }
             try {
-                return (EventContent) RawContent.Deserialize(GetType)!;
+                return (EventContent)RawContent.Deserialize(GetType)!;
             }
             catch (JsonException e) {
                 Console.WriteLine(e);
@@ -122,6 +125,61 @@ public class StateEvent {
     public string cdtype => TypedContent.GetType().Name;
 }
 
+public class StateEventResponse : StateEvent {
+    [JsonPropertyName("origin_server_ts")]
+    public ulong OriginServerTs { get; set; }
+
+    [JsonPropertyName("room_id")]
+    public string RoomId { get; set; }
+
+    [JsonPropertyName("sender")]
+    public string Sender { get; set; }
+
+    [JsonPropertyName("unsigned")]
+    public UnsignedData? Unsigned { get; set; }
+
+    [JsonPropertyName("event_id")]
+    public string EventId { get; set; }
+
+    [JsonPropertyName("user_id")]
+    public string UserId { get; set; }
+
+    [JsonPropertyName("replaces_state")]
+    public new string ReplacesState { get; set; }
+
+    public class UnsignedData {
+        [JsonPropertyName("age")]
+        public ulong? Age { get; set; }
+
+        [JsonPropertyName("redacted_because")]
+        public object? RedactedBecause { get; set; }
+
+        [JsonPropertyName("transaction_id")]
+        public string? TransactionId { get; set; }
+
+        [JsonPropertyName("replaces_state")]
+        public string? ReplacesState { get; set; }
+
+        [JsonPropertyName("prev_sender")]
+        public string? PrevSender { get; set; }
+
+        [JsonPropertyName("prev_content")]
+        public JsonObject? PrevContent { get; set; }
+    }
+}
+
+public class EventList {
+    [JsonPropertyName("events")]
+    public List<StateEventResponse>? Events { get; set; } = new();
+}
+
+public class ChunkedStateEventResponse {
+    [JsonPropertyName("chunk")]
+    public List<StateEventResponse>? Chunk { get; set; } = new();
+}
+
+#region Unused code
+
 /*
 public class StateEventContentPolymorphicTypeInfoResolver : DefaultJsonTypeInfoResolver
 {
@@ -150,3 +208,5 @@ public class StateEventContentPolymorphicTypeInfoResolver : DefaultJsonTypeInfoR
     }
 }
 */
+
+#endregion
diff --git a/Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs b/Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs
index abd3e99..e23d4f4 100644
--- a/Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs
+++ b/Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs
@@ -6,7 +6,7 @@ namespace LibMatrix.Tests.Abstractions;
 
 public static class HomeserverAbstraction {
     public static async Task<AuthenticatedHomeserverGeneric> GetHomeserver() {
-        var rhs = new RemoteHomeServer("https://matrixunittests.rory.gay");
+        var rhs = await RemoteHomeServer.Create("https://matrixunittests.rory.gay");
         // string username = Guid.NewGuid().ToString();
         // string password = Guid.NewGuid().ToString();
         string username = "@f1a2d2d6-1924-421b-91d0-893b347b2a49:matrixunittests.rory.gay";
@@ -25,15 +25,15 @@ public static class HomeserverAbstraction {
 
         var hs = await reg.GetAuthenticatedHomeserver("https://matrixunittests.rory.gay");
 
-        var rooms = await hs.GetJoinedRooms();
+        //var rooms = await hs.GetJoinedRooms();
 
-        var disbandRoomTasks = rooms.Select(async room => {
-            // await room.DisbandRoomAsync();
-            await room.LeaveAsync();
-            await room.ForgetAsync();
-            return room;
-        }).ToList();
-        await Task.WhenAll(disbandRoomTasks);
+        // var disbandRoomTasks = rooms.Select(async room => {
+        //     // await room.DisbandRoomAsync();
+        //     await room.LeaveAsync();
+        //     await room.ForgetAsync();
+        //     return room;
+        // }).ToList();
+        // await Task.WhenAll(disbandRoomTasks);
 
         // foreach (var room in rooms) {
         //     // await room.DisbandRoomAsync();
@@ -45,28 +45,28 @@ public static class HomeserverAbstraction {
     }
 
     public static async Task<AuthenticatedHomeserverGeneric> GetRandomHomeserver() {
-        var rhs = new RemoteHomeServer("https://matrixunittests.rory.gay");
+        var rhs = await RemoteHomeServer.Create("https://matrixunittests.rory.gay");
         LoginResponse reg = await rhs.RegisterAsync(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "Unit tests!");
         var hs = await reg.GetAuthenticatedHomeserver("https://matrixunittests.rory.gay");
 
-        var rooms = await hs.GetJoinedRooms();
-
-        var disbandRoomTasks = rooms.Select(async room => {
-            // await room.DisbandRoomAsync();
-            await room.LeaveAsync();
-            await room.ForgetAsync();
-            return room;
-        }).ToList();
-        await Task.WhenAll(disbandRoomTasks);
+        // var rooms = await hs.GetJoinedRooms();
+        //
+        // var disbandRoomTasks = rooms.Select(async room => {
+        //     // await room.DisbandRoomAsync();
+        //     await room.LeaveAsync();
+        //     await room.ForgetAsync();
+        //     return room;
+        // }).ToList();
+        // await Task.WhenAll(disbandRoomTasks);
 
         return hs;
     }
 
     public static async IAsyncEnumerable<AuthenticatedHomeserverGeneric> GetRandomHomeservers(int count = 1) {
-        var createSpaceTasks = Enumerable
+        var createRandomUserTasks = Enumerable
             .Range(0, count)
             .Select(_ => GetRandomHomeserver()).ToAsyncEnumerable();
-        await foreach (var hs in createSpaceTasks) {
+        await foreach (var hs in createRandomUserTasks) {
             yield return hs;
         }
     }
diff --git a/Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs b/Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs
index 44c35da..76b8c8c 100644
--- a/Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs
+++ b/Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs
@@ -8,11 +8,43 @@ namespace LibMatrix.Tests.Abstractions;
 
 public static class RoomAbstraction {
     public static async Task<GenericRoom> GetTestRoom(AuthenticatedHomeserverGeneric hs) {
-        var testRoom = await hs.CreateRoom(new CreateRoomRequest() {
+        var crq = new CreateRoomRequest() {
             Name = "LibMatrix Test Room",
             // Visibility = CreateRoomVisibility.Public,
             RoomAliasName = Guid.NewGuid().ToString()
+        };
+        crq.InitialState ??= new();
+        crq.InitialState.Add(new StateEvent() {
+            Type = "m.room.topic",
+            StateKey = "",
+            TypedContent = new RoomTopicEventContent() {
+                Topic = "LibMatrix Test Room " + DateTime.Now.ToString("O")
+            }
+        });
+        crq.InitialState.Add(new StateEvent() {
+            Type = "m.room.name",
+            StateKey = "",
+            TypedContent = new RoomNameEventContent() {
+                Name = "LibMatrix Test Room " + DateTime.Now.ToString("O")
+            }
+        });
+        crq.InitialState.Add(new StateEvent() {
+            Type = "m.room.avatar",
+            StateKey = "",
+            TypedContent = new RoomAvatarEventContent() {
+                Url = "mxc://conduit.rory.gay/r9KiT0f9eQbv8pv4RxwBZFuzhfKjGWHx"
+            }
+        });
+        crq.InitialState.Add(new StateEvent() {
+            Type = "m.room.aliases",
+            StateKey = "",
+            TypedContent = new RoomAliasEventContent() {
+                Aliases = Enumerable
+                    .Range(0, 100)
+                    .Select(_ => $"#{Guid.NewGuid()}:matrixunittests.rory.gay").ToList()
+            }
         });
+        var testRoom = await hs.CreateRoom(crq);
 
         await testRoom.SendStateEventAsync("gay.rory.libmatrix.unit_test_room", new());
 
diff --git a/Tests/LibMatrix.Tests/Tests/RoomEventTests.cs b/Tests/LibMatrix.Tests/Tests/RoomEventTests.cs
new file mode 100644
index 0000000..6828087
--- /dev/null
+++ b/Tests/LibMatrix.Tests/Tests/RoomEventTests.cs
@@ -0,0 +1,160 @@
+using System.Text;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Homeservers;
+using LibMatrix.Services;
+using LibMatrix.Tests.Abstractions;
+using LibMatrix.Tests.Fixtures;
+using Xunit.Abstractions;
+using Xunit.Microsoft.DependencyInjection.Abstracts;
+
+namespace LibMatrix.Tests.Tests;
+
+public class RoomEventTests : TestBed<TestFixture> {
+    private readonly TestFixture _fixture;
+    private readonly HomeserverResolverService _resolver;
+    private readonly Config _config;
+    private readonly HomeserverProviderService _provider;
+
+    public RoomEventTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : base(testOutputHelper, fixture) {
+        _fixture = fixture;
+        _resolver = _fixture.GetService<HomeserverResolverService>(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(HomeserverResolverService)}");
+        _config = _fixture.GetService<Config>(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(Config)}");
+        _provider = _fixture.GetService<HomeserverProviderService>(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(HomeserverProviderService)}");
+    }
+
+    private async Task<AuthenticatedHomeserverGeneric> GetHomeserver() {
+        return await HomeserverAbstraction.GetHomeserver();
+    }
+
+    [Fact]
+    public async Task GetNameAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var name = await room.GetNameAsync();
+        Assert.NotNull(name);
+        Assert.NotEmpty(name);
+    }
+
+    [SkippableFact(typeof(MatrixException))]
+    public async Task GetTopicAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var topic = await room.GetTopicAsync();
+        Assert.NotNull(topic);
+        Assert.NotNull(topic.Topic);
+        Assert.NotEmpty(topic.Topic);
+    }
+
+    [SkippableFact(typeof(MatrixException))]
+    public async Task GetAliasesAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var aliases = await room.GetAliasesAsync();
+        Assert.NotNull(aliases);
+        Assert.NotEmpty(aliases);
+        Assert.All(aliases, Assert.NotNull);
+    }
+
+    [SkippableFact(typeof(MatrixException))]
+    public async Task GetCanonicalAliasAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var alias = await room.GetCanonicalAliasAsync();
+        Assert.NotNull(alias);
+        Assert.NotNull(alias.Alias);
+        Assert.NotEmpty(alias.Alias);
+    }
+
+    [SkippableFact(typeof(MatrixException))]
+    public async Task GetAvatarUrlAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var url = await room.GetAvatarUrlAsync();
+        Assert.NotNull(url);
+        Assert.NotNull(url.Url);
+        Assert.NotEmpty(url.Url);
+    }
+
+    [Fact]
+    public async Task GetJoinRuleAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var rule = await room.GetJoinRuleAsync();
+        Assert.NotNull(rule);
+        Assert.NotNull(rule.JoinRule);
+        Assert.NotEmpty(rule.JoinRule);
+    }
+
+    [Fact]
+    public async Task GetHistoryVisibilityAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var visibility = await room.GetHistoryVisibilityAsync();
+        Assert.NotNull(visibility);
+        Assert.NotNull(visibility.HistoryVisibility);
+        Assert.NotEmpty(visibility.HistoryVisibility);
+    }
+
+    [Fact]
+    public async Task GetGuestAccessAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        try {
+            var access = await room.GetGuestAccessAsync();
+            Assert.NotNull(access);
+            Assert.NotNull(access.GuestAccess);
+            Assert.NotEmpty(access.GuestAccess);
+        }
+        catch (Exception e) {
+            if (e is not MatrixException exception) throw;
+            Assert.Equal("M_NOT_FOUND", exception.ErrorCode);
+        }
+    }
+
+    [Fact]
+    public async Task GetCreateEventAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var create = await room.GetCreateEventAsync();
+        Assert.NotNull(create);
+        Assert.NotNull(create.Creator);
+        Assert.NotEmpty(create.RoomVersion!);
+    }
+
+    [Fact]
+    public async Task GetRoomType() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        await room.GetRoomType();
+    }
+
+    [Fact]
+    public async Task GetPowerLevelsAsync() {
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        var room = await RoomAbstraction.GetTestRoom(hs);
+        Assert.NotNull(room);
+        var power = await room.GetPowerLevelsAsync();
+        Assert.NotNull(power);
+        Assert.NotNull(power.Ban);
+        Assert.NotNull(power.Kick);
+        Assert.NotNull(power.Invite);
+        Assert.NotNull(power.Redact);
+        Assert.NotNull(power.StateDefault);
+        Assert.NotNull(power.EventsDefault);
+        Assert.NotNull(power.UsersDefault);
+        Assert.NotNull(power.Users);
+        // Assert.NotNull(power.Events);
+    }
+
+}
diff --git a/Tests/LibMatrix.Tests/Tests/RoomTests.cs b/Tests/LibMatrix.Tests/Tests/RoomTests.cs
index 17c219d..72a2775 100644
--- a/Tests/LibMatrix.Tests/Tests/RoomTests.cs
+++ b/Tests/LibMatrix.Tests/Tests/RoomTests.cs
@@ -31,38 +31,18 @@ public class RoomTests : TestBed<TestFixture> {
     public async Task GetJoinedRoomsAsync() {
         var hs = await HomeserverAbstraction.GetHomeserver();
         //make 100 rooms
-        var createRoomTasks = Enumerable.Range(0, 100).Select(_ => RoomAbstraction.GetTestRoom(hs)).ToList();
+        var createRoomTasks = Enumerable.Range(0, 10).Select(_ => RoomAbstraction.GetTestRoom(hs)).ToList();
         await Task.WhenAll(createRoomTasks);
 
         var rooms = await hs.GetJoinedRooms();
         Assert.NotNull(rooms);
         Assert.NotEmpty(rooms);
         Assert.All(rooms, Assert.NotNull);
-        Assert.Equal(100, rooms.Count);
+        Assert.True(rooms.Count >= 10, "Not enough rooms were found");
 
         await hs.Logout();
     }
 
-    [Fact]
-    public async Task GetNameAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var name = await room.GetNameAsync();
-        Assert.NotNull(name);
-        Assert.NotEmpty(name);
-    }
-
-    [SkippableFact(typeof(MatrixException))]
-    public async Task GetTopicAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var topic = await room.GetTopicAsync();
-        Assert.NotNull(topic);
-        Assert.NotNull(topic.Topic);
-        Assert.NotEmpty(topic.Topic);
-    }
 
     [Fact]
     public async Task GetMembersAsync() {
@@ -106,115 +86,6 @@ public class RoomTests : TestBed<TestFixture> {
         Assert.NotEmpty(id.RoomId);
     }
 
-    [SkippableFact(typeof(MatrixException))]
-    public async Task GetAliasesAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var aliases = await room.GetAliasesAsync();
-        Assert.NotNull(aliases);
-        Assert.NotEmpty(aliases);
-        Assert.All(aliases, Assert.NotNull);
-    }
-
-    [SkippableFact(typeof(MatrixException))]
-    public async Task GetCanonicalAliasAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var alias = await room.GetCanonicalAliasAsync();
-        Assert.NotNull(alias);
-        Assert.NotNull(alias.Alias);
-        Assert.NotEmpty(alias.Alias);
-    }
-
-    [SkippableFact(typeof(MatrixException))]
-    public async Task GetAvatarUrlAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var url = await room.GetAvatarUrlAsync();
-        Assert.NotNull(url);
-        Assert.NotNull(url.Url);
-        Assert.NotEmpty(url.Url);
-    }
-
-    [Fact]
-    public async Task GetJoinRuleAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var rule = await room.GetJoinRuleAsync();
-        Assert.NotNull(rule);
-        Assert.NotNull(rule.JoinRule);
-        Assert.NotEmpty(rule.JoinRule);
-    }
-
-    [Fact]
-    public async Task GetHistoryVisibilityAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var visibility = await room.GetHistoryVisibilityAsync();
-        Assert.NotNull(visibility);
-        Assert.NotNull(visibility.HistoryVisibility);
-        Assert.NotEmpty(visibility.HistoryVisibility);
-    }
-
-    [Fact]
-    public async Task GetGuestAccessAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        try {
-            var access = await room.GetGuestAccessAsync();
-            Assert.NotNull(access);
-            Assert.NotNull(access.GuestAccess);
-            Assert.NotEmpty(access.GuestAccess);
-        }
-        catch (Exception e) {
-            if (e is not MatrixException exception) throw;
-            Assert.Equal("M_NOT_FOUND", exception.ErrorCode);
-        }
-    }
-
-    [Fact]
-    public async Task GetCreateEventAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var create = await room.GetCreateEventAsync();
-        Assert.NotNull(create);
-        Assert.NotNull(create.Creator);
-        Assert.NotEmpty(create.RoomVersion!);
-    }
-
-    [Fact]
-    public async Task GetRoomType() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        await room.GetRoomType();
-    }
-
-    [Fact]
-    public async Task GetPowerLevelsAsync() {
-        var hs = await HomeserverAbstraction.GetHomeserver();
-        var room = await RoomAbstraction.GetTestRoom(hs);
-        Assert.NotNull(room);
-        var power = await room.GetPowerLevelsAsync();
-        Assert.NotNull(power);
-        Assert.NotNull(power.Ban);
-        Assert.NotNull(power.Kick);
-        Assert.NotNull(power.Invite);
-        Assert.NotNull(power.Redact);
-        Assert.NotNull(power.StateDefault);
-        Assert.NotNull(power.EventsDefault);
-        Assert.NotNull(power.UsersDefault);
-        Assert.NotNull(power.Users);
-        // Assert.NotNull(power.Events);
-    }
-
     [Fact]
     public async Task ForgetAsync() {
         var hs = await HomeserverAbstraction.GetHomeserver();
@@ -361,17 +232,21 @@ public class RoomTests : TestBed<TestFixture> {
 
     [Fact]
     public async Task InviteAndJoinAsync() {
-        var otherUsers = HomeserverAbstraction.GetRandomHomeservers(7);
         var hs = await HomeserverAbstraction.GetHomeserver();
         var room = await RoomAbstraction.GetTestRoom(hs);
+        var otherUsers = HomeserverAbstraction.GetRandomHomeservers(15);
         Assert.NotNull(room);
 
         // var expectedCount = 1;
 
+        var tasks = new List<Task>();
         await foreach(var otherUser in otherUsers) {
-            await room.InviteUserAsync(otherUser.UserId);
-            await otherUser.GetRoom(room.RoomId).JoinAsync();
+            tasks.Add(Task.Run(async () => {
+                await room.InviteUserAsync(otherUser.UserId);
+                await otherUser.GetRoom(room.RoomId).JoinAsync();
+            }));
         }
+        await Task.WhenAll(tasks);
 
         var states = room.GetMembersAsync(false);
         var count = 0;
@@ -379,6 +254,6 @@ public class RoomTests : TestBed<TestFixture> {
             count++;
         }
         // Assert.Equal(++expectedCount, count);
-        Assert.Equal(8, count);
+        Assert.Equal(16, count);
     }
 }
diff --git a/Tests/LibMatrix.Tests/Tests/TestCleanup.cs b/Tests/LibMatrix.Tests/Tests/TestCleanup.cs
new file mode 100644
index 0000000..e93de3d
--- /dev/null
+++ b/Tests/LibMatrix.Tests/Tests/TestCleanup.cs
@@ -0,0 +1,76 @@
+using System.Diagnostics;
+using ArcaneLibs.Extensions;
+using LibMatrix.Helpers;
+using LibMatrix.Services;
+using LibMatrix.Tests.Abstractions;
+using LibMatrix.Tests.DataTests;
+using LibMatrix.Tests.Fixtures;
+using Microsoft.Extensions.Logging;
+using Xunit.Abstractions;
+using Xunit.Microsoft.DependencyInjection.Abstracts;
+
+namespace LibMatrix.Tests.Tests;
+
+public class TestCleanup : TestBed<TestFixture> {
+    private readonly TestFixture _fixture;
+    private readonly HomeserverResolverService _resolver;
+    private readonly Config _config;
+    private readonly HomeserverProviderService _provider;
+    private readonly ILogger<TestCleanup> _logger;
+
+    public TestCleanup(ITestOutputHelper testOutputHelper, TestFixture fixture) : base(testOutputHelper, fixture) {
+        _fixture = fixture;
+        _resolver = _fixture.GetService<HomeserverResolverService>(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(HomeserverResolverService)}");
+        _config = _fixture.GetService<Config>(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(Config)}");
+        _provider = _fixture.GetService<HomeserverProviderService>(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(HomeserverProviderService)}");
+        _logger = _fixture.GetService<ILogger<TestCleanup>>(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(ILogger<TestCleanup>)}");
+    }
+
+    [Fact]
+    public async Task Cleanup() {
+        Assert.False(string.IsNullOrWhiteSpace(_config.TestHomeserver), $"{nameof(_config.TestHomeserver)} must be set in appsettings!");
+        Assert.False(string.IsNullOrWhiteSpace(_config.TestUsername), $"{nameof(_config.TestUsername)} must be set in appsettings!");
+        Assert.False(string.IsNullOrWhiteSpace(_config.TestPassword), $"{nameof(_config.TestPassword)} must be set in appsettings!");
+
+        var hs = await HomeserverAbstraction.GetHomeserver();
+        Assert.NotNull(hs);
+
+        var syncHelper = new SyncHelper(hs, _logger) {
+            Timeout = 3000
+        };
+        _testOutputHelper.WriteLine("Starting sync loop");
+        var cancellationTokenSource = new CancellationTokenSource();
+        var sw = Stopwatch.StartNew();
+        syncHelper.SyncReceivedHandlers.Add(async response => {
+            if (sw.ElapsedMilliseconds >= 3000) {
+                _testOutputHelper.WriteLine("Cancelling sync loop");
+
+                var tasks = (await hs.GetJoinedRooms()).Select(async room => {
+                    _logger.LogInformation("Leaving room: {}", room.RoomId);
+                    await room.LeaveAsync();
+                    await room.ForgetAsync();
+                    return room;
+                }).ToList();
+                await Task.WhenAll(tasks);
+
+                cancellationTokenSource.Cancel();
+            }
+
+            sw.Restart();
+            if (response.Rooms?.Leave is { Count: > 0 }) {
+                // foreach (var room in response.Rooms.Leave) {
+                    // await hs.GetRoom(room.Key).ForgetAsync();
+                // }
+                var tasks = response.Rooms.Leave.Select(async room => {
+                    await hs.GetRoom(room.Key).ForgetAsync();
+                    return room;
+                }).ToList();
+                await Task.WhenAll(tasks);
+            }
+        });
+        await syncHelper.RunSyncLoopAsync(cancellationToken: cancellationTokenSource.Token);
+
+        Assert.NotNull(hs);
+        await hs.Logout();
+    }
+}
diff --git a/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs b/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs
index 910db0a..0dcf3e3 100644
--- a/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs
+++ b/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs
@@ -37,7 +37,8 @@ public class CommandListenerHostedService : IHostedService {
 
     private async Task? Run(CancellationToken cancellationToken) {
         _logger.LogInformation("Starting command listener!");
-        _hs.SyncHelper.TimelineEventHandlers.Add(async @event => {
+        var syncHelper = new SyncHelper(_hs);
+        syncHelper.TimelineEventHandlers.Add(async @event => {
             try {
                 var room = _hs.GetRoom(@event.RoomId);
                 // _logger.LogInformation(eventResponse.ToJson(indent: false));
@@ -80,7 +81,7 @@ public class CommandListenerHostedService : IHostedService {
                 _logger.LogError(e, "Error in command listener!");
             }
         });
-        await _hs.SyncHelper.RunSyncLoop(cancellationToken: cancellationToken);
+        await new SyncHelper(_hs).RunSyncLoopAsync(cancellationToken: cancellationToken);
     }
 
     /// <summary>Triggered when the application host is performing a graceful shutdown.</summary>