diff --git a/LibMatrix.EventTypes/Common/MjolnirShortcodeEventContent.cs b/LibMatrix.EventTypes/Common/MjolnirShortcodeEventContent.cs
index a31cbbb..a1ebd79 100644
--- a/LibMatrix.EventTypes/Common/MjolnirShortcodeEventContent.cs
+++ b/LibMatrix.EventTypes/Common/MjolnirShortcodeEventContent.cs
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
namespace LibMatrix.EventTypes.Common;
[MatrixEvent(EventName = EventId)]
-public class MjolnirShortcodeEventContent : TimelineEventContent {
+public class MjolnirShortcodeEventContent : EventContent {
public const string EventId = "org.matrix.mjolnir.shortcode";
[JsonPropertyName("shortcode")]
diff --git a/LibMatrix.EventTypes/EventContent.cs b/LibMatrix.EventTypes/EventContent.cs
index c582cf2..07f56e2 100644
--- a/LibMatrix.EventTypes/EventContent.cs
+++ b/LibMatrix.EventTypes/EventContent.cs
@@ -1,10 +1,23 @@
+using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace LibMatrix.EventTypes;
-public abstract class EventContent;
+public abstract class EventContent {
+ [JsonExtensionData]
+ public Dictionary<string, object>? AdditionalData { get; set; } = [];
+
+ public static List<string> GetMatchingEventTypes<T>() where T : EventContent {
+ var type = typeof(T);
+ var eventTypes = new List<string>();
+ foreach (var attr in type.GetCustomAttributes<MatrixEventAttribute>(true)) {
+ eventTypes.Add(attr.EventName);
+ }
+ return eventTypes;
+ }
+}
public class UnknownEventContent : TimelineEventContent;
@@ -37,6 +50,13 @@ public abstract class TimelineEventContent : EventContent {
[JsonPropertyName("rel_type")]
public string? RelationType { get; set; }
+ // used for reactions
+ [JsonPropertyName("key")]
+ public string? Key { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary<string, object>? AdditionalData { get; set; } = [];
+
public class EventInReplyTo {
[JsonPropertyName("event_id")]
public string? EventId { get; set; }
diff --git a/LibMatrix.EventTypes/Interop/Draupnir/DraupnirProtectedRoomsData.cs b/LibMatrix.EventTypes/Interop/Draupnir/DraupnirProtectedRoomsData.cs
new file mode 100644
index 0000000..1917239
--- /dev/null
+++ b/LibMatrix.EventTypes/Interop/Draupnir/DraupnirProtectedRoomsData.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.EventTypes.Interop.Draupnir;
+
+[MatrixEvent(EventName = EventId)]
+public class DraupnirProtectedRoomsData : EventContent {
+ public const string EventId = "org.matrix.mjolnir.protected_rooms";
+
+ [JsonPropertyName("rooms")]
+ public List<string> Rooms { get; set; } = new();
+}
\ No newline at end of file
diff --git a/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj b/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj
index 4276003..0924aba 100644
--- a/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj
+++ b/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj
@@ -1,22 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net8.0</TargetFramework>
+ <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
- <ProjectReference Condition="Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')" Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj"/>
- <!-- This is dangerous, but eases development since locking the version will drift out of sync without noticing,
- which causes build errors due to missing functions.
- Using the NuGet version in development is annoying due to delays between pushing and being able to consume.
- If you want to use a time-appropriate version of the library, recursively clone https://cgit.rory.gay/matrix/MatrixUtils.git
- instead, since this will be locked by the MatrixUtils project, which contains both LibMatrix and ArcaneLibs as a submodule. -->
- <PackageReference Condition="!Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')" Include="ArcaneLibs" Version="*-preview*"/>
+ <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250419-174711" Condition="'$(Configuration)' == 'Release'" />
+ <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/>
</ItemGroup>
- <Target Name="ArcaneLibsNugetWarning" AfterTargets="AfterBuild">
- <Warning Text="ArcaneLibs is being referenced from NuGet, which is dangerous. Please read the warning in LibMatrix.csproj!" Condition="!Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')"/>
- </Target>
</Project>
diff --git a/LibMatrix.EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs b/LibMatrix.EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs
index 494936d..a7d431c 100644
--- a/LibMatrix.EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.Ephemeral;
[MatrixEvent(EventName = EventId)]
public class RoomTypingEventContent : EventContent {
diff --git a/LibMatrix.EventTypes/Spec/IgnoredUserListEventContent.cs b/LibMatrix.EventTypes/Spec/IgnoredUserListEventContent.cs
new file mode 100644
index 0000000..59e17c9
--- /dev/null
+++ b/LibMatrix.EventTypes/Spec/IgnoredUserListEventContent.cs
@@ -0,0 +1,31 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.EventTypes.Spec;
+
+[MatrixEvent(EventName = EventId)]
+public class IgnoredUserListEventContent : EventContent {
+ public const string EventId = "m.ignored_user_list";
+
+ [JsonPropertyName("ignored_users")]
+ public Dictionary<string, IgnoredUserContent> IgnoredUsers { get; set; } = new();
+
+ // Dummy type to provide easy access to the by-spec empty content
+ public class IgnoredUserContent {
+ [JsonExtensionData]
+ public Dictionary<string, object>? AdditionalData { get; set; } = [];
+
+ public T? GetAdditionalData<T>(string key) where T : class {
+ if (AdditionalData == null || !AdditionalData.TryGetValue(key, out var value))
+ return null;
+
+ if (value is T tValue)
+ return tValue;
+ if (value is JsonElement jsonElement)
+ return jsonElement.Deserialize<T>();
+
+ throw new InvalidCastException($"Value for key '{key}' ({value.GetType()}) cannot be cast to type '{typeof(T)}'. Cannot continue.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs b/LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs
index ae893f8..d1cf8be 100644
--- a/LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs
@@ -29,13 +29,34 @@ public class RoomMessageEventContent : TimelineEventContent {
[JsonPropertyName("url")]
public string? Url { get; set; }
+ [JsonPropertyName("filename")]
public string? FileName { get; set; }
[JsonPropertyName("info")]
public FileInfoStruct? FileInfo { get; set; }
-
+
[JsonIgnore]
- public string BodyWithoutReplyFallback => Body.Split('\n').SkipWhile(x => x.StartsWith(">")).SkipWhile(x=>x.Trim().Length == 0).Aggregate((x, y) => $"{x}\n{y}");
+ public string BodyWithoutReplyFallback {
+ get {
+ var parts = Body
+ .Split('\n')
+ .SkipWhile(x => x.StartsWith(">"))
+ .SkipWhile(x => x.Trim().Length == 0)
+ .ToList();
+ return parts.Count > 0 ? parts.Aggregate((x, y) => $"{x}\n{y}") : Body;
+ }
+ }
+
+ [JsonPropertyName("m.mentions")]
+ public MentionsStruct? Mentions { get; set; }
+
+ public class MentionsStruct {
+ [JsonPropertyName("user_ids")]
+ public List<string>? Users { get; set; }
+
+ [JsonPropertyName("room")]
+ public bool? Room { get; set; }
+ }
public class FileInfoStruct {
[JsonPropertyName("mimetype")]
@@ -46,10 +67,10 @@ public class RoomMessageEventContent : TimelineEventContent {
[JsonPropertyName("thumbnail_url")]
public string? ThumbnailUrl { get; set; }
-
+
[JsonPropertyName("w")]
public int? Width { get; set; }
-
+
[JsonPropertyName("h")]
public int? Height { get; set; }
}
diff --git a/LibMatrix.EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs b/LibMatrix.EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs
index 89e2fdb..d75b19f 100644
--- a/LibMatrix.EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs
@@ -1,5 +1,9 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
using ArcaneLibs.Attributes;
+using ArcaneLibs.Extensions;
namespace LibMatrix.EventTypes.Spec.State.Policy;
@@ -28,9 +32,9 @@ public class RoomPolicyRuleEventContent : PolicyRuleEventContent {
public const string EventId = "m.policy.rule.room";
}
+[DebuggerDisplay("""{GetType().Name.Replace("PolicyRuleEventContent", ""),nq} policy matching {Entity}, Reason: {Reason}""")]
public abstract class PolicyRuleEventContent : EventContent {
- public PolicyRuleEventContent() => Console.WriteLine($"init policy {GetType().Name}");
- private string? _reason;
+ // public PolicyRuleEventContent() => Console.WriteLine($"init policy {GetType().Name}");
/// <summary>
/// Entity this ban applies to, can use * and ? as globs.
@@ -40,24 +44,14 @@ public abstract class PolicyRuleEventContent : EventContent {
[FriendlyName(Name = "Entity")]
public string? Entity { get; set; }
- private bool init;
+ // private bool init;
/// <summary>
/// Reason this user is banned
/// </summary>
[JsonPropertyName("reason")]
[FriendlyName(Name = "Reason")]
- public virtual string? Reason {
- get =>
- // Console.WriteLine($"Read policy reason: {_reason}");
- _reason;
- set =>
- // Console.WriteLine($"Set policy reason: {value}");
- // if(init)
- // Console.WriteLine(string.Join('\n', Environment.StackTrace.Split('\n')[..5]));
- // init = true;
- _reason = value;
- }
+ public string? Reason { get; set; }
/// <summary>
/// Suggested action to take
@@ -87,6 +81,49 @@ public abstract class PolicyRuleEventContent : EventContent {
Expiry = ((DateTimeOffset)value).ToUnixTimeMilliseconds();
}
}
+
+ [JsonPropertyName("org.matrix.msc4205.hashes")]
+ [TableHide]
+ public PolicyHash? Hashes { get; set; }
+
+ public string GetDraupnir2StateKey() => Convert.ToBase64String(SHA256.HashData($"{Entity}{Recommendation}".AsBytes().ToArray()));
+
+ public Regex? GetEntityRegex() => Entity is null ? null : new(Entity.Replace(".", "\\.").Replace("*", ".*").Replace("?", "."));
+
+ public bool IsGlobRule() =>
+ !string.IsNullOrWhiteSpace(Entity)
+ && (Entity.Contains('*') || Entity.Contains('?'));
+
+ public bool EntityMatches(string entity) {
+ if (string.IsNullOrWhiteSpace(entity)) return false;
+
+ if (!string.IsNullOrWhiteSpace(Entity)) {
+ // Check if entity is equal regardless of glob check
+ var match = Entity == entity
+ || (IsGlobRule() && GetEntityRegex()!.IsMatch(entity));
+ if (match) return match;
+ }
+
+ if (Hashes is not null) {
+ if (!string.IsNullOrWhiteSpace(Hashes.Sha256)) {
+ var hash = SHA256.HashData(entity.AsBytes().ToArray());
+ var match = Convert.ToBase64String(hash) == Hashes.Sha256;
+ if (match) return match;
+ }
+ }
+
+ return false;
+ }
+
+ public string? GetNormalizedRecommendation() {
+ if (Recommendation is "m.ban" or "org.matrix.mjolnir.ban")
+ return PolicyRecommendationTypes.Ban;
+
+ if (Recommendation is "m.takedown" or "org.matrix.msc4204.takedown")
+ return "m.takedown";
+
+ return Recommendation;
+ }
}
public static class PolicyRecommendationTypes {
@@ -99,6 +136,13 @@ public static class PolicyRecommendationTypes {
/// Mute this user
/// </summary>
public static string Mute = "support.feline.policy.recommendation_mute"; //stable prefix: m.mute, msc pending
+
+ public static string Takedown = "m.takedown"; //unstable prefix: org.matrix.msc4204.takedown
+}
+
+public class PolicyHash {
+ [JsonPropertyName("sha256")]
+ public string? Sha256 { get; set; }
}
// public class PolicySchemaDefinition {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs
index 93f13ac..ee3234c 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
public class RoomCanonicalAliasEventContent : EventContent {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs
index c619d0e..37b831a 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs
@@ -1,8 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
+[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global", Justification = "Deserialization, public API")]
+[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global", Justification = "Deserialization, public API")]
public class RoomCreateEventContent : EventContent {
public const string EventId = "m.room.create";
@@ -15,17 +18,17 @@ public class RoomCreateEventContent : EventContent {
[JsonPropertyName("m.federate")]
public bool? Federate { get; set; }
- [JsonPropertyName("predecessor")]
- public RoomCreatePredecessor? Predecessor { get; set; }
+ // [JsonPropertyName("predecessor")]
+ // public RoomCreatePredecessor? Predecessor { get; set; }
[JsonPropertyName("type")]
public string? Type { get; set; }
+}
- public class RoomCreatePredecessor {
- [JsonPropertyName("room_id")]
- public string? RoomId { get; set; }
+public class RoomCreatePredecessor {
+ [JsonPropertyName("room_id")]
+ public string? RoomId { get; set; }
- [JsonPropertyName("event_id")]
- public string? EventId { get; set; }
- }
+ [JsonPropertyName("event_id")]
+ public string? EventId { get; set; }
}
\ No newline at end of file
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs
index b49abfa..16209f0 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
public class RoomEncryptionEventContent : EventContent {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs
index a7811bf..1ba5a3f 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs
@@ -1,13 +1,13 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
public class RoomGuestAccessEventContent : EventContent {
public const string EventId = "m.room.guest_access";
[JsonPropertyName("guest_access")]
- public string GuestAccess { get; set; }
+ public required string GuestAccess { get; set; }
[JsonIgnore]
public bool IsGuestAccessEnabled {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs
index 7676dad..8edf4a7 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs
@@ -1,11 +1,17 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
public class RoomHistoryVisibilityEventContent : EventContent {
public const string EventId = "m.room.history_visibility";
[JsonPropertyName("history_visibility")]
- public string HistoryVisibility { get; set; }
+ public required string HistoryVisibility { get; set; }
+
+ public static class HistoryVisibilityTypes {
+ public const string WorldReadable = "world_readable";
+ public const string Invited = "invited";
+ public const string Shared = "shared";
+ }
}
\ No newline at end of file
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs
index 349c8a7..03d994d 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
public class RoomJoinRulesEventContent : EventContent {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs
index b2d5596..b034425 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
public class RoomMemberEventContent : EventContent {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs
index 3ea5730..415c675 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
public class RoomNameEventContent : EventContent {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs
index b4474e9..7eee605 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
public class RoomPinnedEventContent : EventContent {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPolicyServerEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPolicyServerEventContent.cs
new file mode 100644
index 0000000..80e254f
--- /dev/null
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPolicyServerEventContent.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
+
+[MatrixEvent(EventName = EventId)]
+public class RoomPolicyServerEventContent : EventContent {
+ public const string EventId = "org.matrix.msc4284.policy";
+
+ [JsonPropertyName("via")]
+ public string? Via { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs
index eb156b3..22fa3b7 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
@@ -20,7 +20,7 @@ public class RoomPowerLevelEventContent : EventContent {
public long? Kick { get; set; } = 50;
[JsonPropertyName("notifications")]
- public NotificationsPL? NotificationsPl { get; set; } // = null!;
+ public NotificationsPowerLevels? NotificationsPl { get; set; }
[JsonPropertyName("redact")]
public long? Redact { get; set; } = 50;
@@ -29,10 +29,10 @@ public class RoomPowerLevelEventContent : EventContent {
public long? StateDefault { get; set; } = 50;
[JsonPropertyName("events")]
- public Dictionary<string, long>? Events { get; set; } // = null!;
+ public Dictionary<string, long>? Events { get; set; }
[JsonPropertyName("users")]
- public Dictionary<string, long>? Users { get; set; } // = null!;
+ public Dictionary<string, long>? Users { get; set; }
[JsonPropertyName("users_default")]
public long? UsersDefault { get; set; } = 0;
@@ -42,19 +42,19 @@ public class RoomPowerLevelEventContent : EventContent {
[JsonPropertyName("historical")]
public long Historical { get; set; } // = 50;
- public class NotificationsPL {
+ public class NotificationsPowerLevels {
[JsonPropertyName("room")]
public long Room { get; set; } = 50;
}
public bool IsUserAdmin(string userId) {
ArgumentNullException.ThrowIfNull(userId);
- return Users.TryGetValue(userId, out var level) && level >= Events.Max(x => x.Value);
+ return GetUserPowerLevel(userId) >= Events?.Max(x => x.Value);
}
public bool UserHasTimelinePermission(string userId, string eventType) {
ArgumentNullException.ThrowIfNull(userId);
- return Users.TryGetValue(userId, out var level) && level >= Events.GetValueOrDefault(eventType, EventsDefault ?? 0);
+ return GetUserPowerLevel(userId) >= Events?.GetValueOrDefault(eventType, EventsDefault ?? 0);
}
public bool UserHasStatePermission(string userId, string eventType, bool log = false) {
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomRedactionEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomRedactionEventContent.cs
new file mode 100644
index 0000000..055f22d
--- /dev/null
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomRedactionEventContent.cs
@@ -0,0 +1,18 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
+
+[MatrixEvent(EventName = EventId)]
+public class RoomRedactionEventContent : EventContent {
+ public const string EventId = "m.room.redaction";
+
+ [JsonPropertyName("reason")]
+ public string? Reason { get; set; }
+
+ /// <summary>
+ /// Required in room version 11
+ /// </summary>
+ [JsonPropertyName("redacts")]
+ public string? Redacts { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs
index be83e37..c492250 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs
@@ -1,16 +1,16 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
-public class RoomServerACLEventContent : EventContent {
+public class RoomServerAclEventContent : EventContent {
public const string EventId = "m.room.server_acl";
[JsonPropertyName("allow")]
- public List<string>? Allow { get; set; } // = null!;
+ public List<string>? Allow { get; set; }
[JsonPropertyName("deny")]
- public List<string>? Deny { get; set; } // = null!;
+ public List<string>? Deny { get; set; }
[JsonPropertyName("allow_ip_literals")]
public bool AllowIpLiterals { get; set; } // = false;
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomTombstoneEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomTombstoneEventContent.cs
new file mode 100644
index 0000000..2c45c41
--- /dev/null
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomTombstoneEventContent.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
+
+[MatrixEvent(EventName = EventId)]
+public class RoomTombstoneEventContent : EventContent {
+ public const string EventId = "m.room.tombstone";
+
+ [JsonPropertyName("body")]
+ public string Body { get; set; }
+
+ [JsonPropertyName("replacement_room")]
+ public string ReplacementRoom { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs
index 92fa75d..065c976 100644
--- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.RoomInfo;
[MatrixEvent(EventName = EventId)]
[MatrixEvent(EventName = "org.matrix.msc3765.topic", Legacy = true)]
diff --git a/LibMatrix.EventTypes/Spec/State/Space/SpaceChildEventContent.cs b/LibMatrix.EventTypes/Spec/State/Space/SpaceChildEventContent.cs
index d233be4..cd0f1f5 100644
--- a/LibMatrix.EventTypes/Spec/State/Space/SpaceChildEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/Space/SpaceChildEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.Space;
[MatrixEvent(EventName = EventId)]
public class SpaceChildEventContent : EventContent {
diff --git a/LibMatrix.EventTypes/Spec/State/Space/SpaceParentEventContent.cs b/LibMatrix.EventTypes/Spec/State/Space/SpaceParentEventContent.cs
index 2ab79a4..f50797e 100644
--- a/LibMatrix.EventTypes/Spec/State/Space/SpaceParentEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/State/Space/SpaceParentEventContent.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.EventTypes.Spec.State;
+namespace LibMatrix.EventTypes.Spec.State.Space;
[MatrixEvent(EventName = EventId)]
public class SpaceParentEventContent : EventContent {
diff --git a/LibMatrix.Federation/AuthenticatedFederationClient.cs b/LibMatrix.Federation/AuthenticatedFederationClient.cs
new file mode 100644
index 0000000..6f8d44b
--- /dev/null
+++ b/LibMatrix.Federation/AuthenticatedFederationClient.cs
@@ -0,0 +1,20 @@
+using LibMatrix.Homeservers;
+
+namespace LibMatrix.Federation;
+
+public class AuthenticatedFederationClient : FederationClient {
+
+ public class AuthenticatedFederationConfiguration {
+
+ }
+ public AuthenticatedFederationClient(string federationEndpoint, AuthenticatedFederationConfiguration config, string? proxy = null) : base(federationEndpoint, proxy)
+ {
+
+ }
+
+ // public async Task<UserDeviceListResponse> GetUserDevicesAsync(string userId) {
+ // var response = await GetAsync<UserDeviceListResponse>($"/_matrix/federation/v1/user/devices/{userId}", accessToken);
+ // return response;
+ // }
+
+}
\ No newline at end of file
diff --git a/LibMatrix.Federation/Extensions/Ed25519Extensions.cs b/LibMatrix.Federation/Extensions/Ed25519Extensions.cs
new file mode 100644
index 0000000..69baf58
--- /dev/null
+++ b/LibMatrix.Federation/Extensions/Ed25519Extensions.cs
@@ -0,0 +1,8 @@
+using LibMatrix.FederationTest.Utilities;
+using Org.BouncyCastle.Crypto.Parameters;
+
+namespace LibMatrix.Federation.Extensions;
+
+public static class Ed25519Extensions {
+ public static string ToUnpaddedBase64(this Ed25519PublicKeyParameters key) => UnpaddedBase64.Encode(key.GetEncoded());
+}
\ No newline at end of file
diff --git a/LibMatrix.Federation/LibMatrix.Federation.csproj b/LibMatrix.Federation/LibMatrix.Federation.csproj
new file mode 100644
index 0000000..78086bb
--- /dev/null
+++ b/LibMatrix.Federation/LibMatrix.Federation.csproj
@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <LangVersion>preview</LangVersion>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\LibMatrix\LibMatrix.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="BouncyCastle.Cryptography" Version="2.6.1" />
+ <PackageReference Include="Microsoft.Extensions.Primitives" Version="10.0.0-preview.5.25277.114" />
+ </ItemGroup>
+
+</Project>
diff --git a/LibMatrix.Federation/Utilities/JsonSigning.cs b/LibMatrix.Federation/Utilities/JsonSigning.cs
new file mode 100644
index 0000000..c727cde
--- /dev/null
+++ b/LibMatrix.Federation/Utilities/JsonSigning.cs
@@ -0,0 +1,108 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+using LibMatrix.Extensions;
+using LibMatrix.FederationTest.Utilities;
+using LibMatrix.Homeservers;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Math.EC.Rfc8032;
+
+namespace LibMatrix.Federation.Utilities;
+
+public static class JsonSigning { }
+
+[JsonConverter(typeof(SignedObjectConverterFactory))]
+public class SignedObject<T> {
+ [JsonPropertyName("signatures")]
+ public Dictionary<string, Dictionary<string, string>> Signatures { get; set; } = new();
+
+ [JsonIgnore]
+ public Dictionary<string, Dictionary<ServerKeysResponse.VersionedKeyId, string>> VerifyKeysById {
+ get => Signatures.ToDictionary(server => server.Key, server => server.Value.ToDictionary(key => (ServerKeysResponse.VersionedKeyId)key.Key, key => key.Value));
+ set => Signatures = value.ToDictionary(server => server.Key, server => server.Value.ToDictionary(key => (string)key.Key, key => key.Value));
+ }
+
+ [JsonExtensionData]
+ public required JsonObject Content { get; set; }
+
+ [JsonIgnore]
+ public T TypedContent {
+ get => Content.Deserialize<T>() ?? throw new JsonException("Failed to deserialize TypedContent from Content.");
+ set => Content = JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(value)) ?? new JsonObject();
+ }
+}
+
+// Content needs to be merged at toplevel
+public class SignedObjectConverter<T> : JsonConverter<SignedObject<T>> {
+ public override SignedObject<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
+ var jsonObject = JsonSerializer.Deserialize<JsonObject>(ref reader, options);
+ if (jsonObject == null) {
+ throw new JsonException("Failed to deserialize SignedObject, JSON object is null.");
+ }
+
+ var signatures = jsonObject["signatures"] ?? throw new JsonException("Failed to find 'signatures' property in JSON object.");
+ jsonObject.Remove("signatures");
+
+ var signedObject = new SignedObject<T> {
+ Content = jsonObject,
+ Signatures = signatures.Deserialize<Dictionary<string, Dictionary<string, string>>>()
+ ?? throw new JsonException("Failed to deserialize 'signatures' property into Dictionary<string, Dictionary<string, string>>.")
+ };
+
+ return signedObject;
+ }
+
+ public override void Write(Utf8JsonWriter writer, SignedObject<T> value, JsonSerializerOptions options) {
+ var targetObj = value.Content.DeepClone();
+ targetObj["signatures"] = value.Signatures.ToJsonNode();
+ JsonSerializer.Serialize(writer, targetObj, options);
+ }
+}
+
+internal class SignedObjectConverterFactory : JsonConverterFactory {
+ public override bool CanConvert(Type typeToConvert) {
+ if (!typeToConvert.IsGenericType) return false;
+ return typeToConvert.GetGenericTypeDefinition() == typeof(SignedObject<>);
+ }
+
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
+ var wrappedType = typeToConvert.GetGenericArguments()[0];
+ var converter = (JsonConverter)Activator.CreateInstance(typeof(SignedObjectConverter<>).MakeGenericType(wrappedType))!;
+ return converter;
+ }
+}
+
+public static class ObjectExtensions {
+ public static SignedObject<T> Sign<T>(this SignedObject<T> content, string serverName, string keyName, Ed25519PrivateKeyParameters key) {
+ var signResult = Sign(content.Content, serverName, keyName, key);
+ var signedObject = new SignedObject<T> {
+ Signatures = content.Signatures,
+ Content = signResult.Content
+ };
+
+ if (!signedObject.Signatures.ContainsKey(serverName))
+ signedObject.Signatures[serverName] = new Dictionary<string, string>();
+
+ signedObject.Signatures[serverName][keyName] = signResult.Signatures[serverName][keyName];
+ return signedObject;
+ }
+
+ public static SignedObject<T> Sign<T>(this T content, string serverName, string keyName, Ed25519PrivateKeyParameters key) {
+ SignedObject<T> signedObject = new() {
+ Signatures = [],
+ Content = JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(content)) ?? new JsonObject(),
+ };
+
+ var contentBytes = CanonicalJsonSerializer.SerializeToUtf8Bytes(signedObject.Content);
+ var signature = new byte[Ed25519.SignatureSize];
+ key.Sign(Ed25519.Algorithm.Ed25519, null, contentBytes, 0, contentBytes.Length, signature, 0);
+
+ if (!signedObject.Signatures.ContainsKey(serverName))
+ signedObject.Signatures[serverName] = new Dictionary<string, string>();
+
+ signedObject.Signatures[serverName][keyName] = UnpaddedBase64.Encode(signature);
+ return signedObject;
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix.Federation/Utilities/UnpaddedBase64.cs b/LibMatrix.Federation/Utilities/UnpaddedBase64.cs
new file mode 100644
index 0000000..06f84b2
--- /dev/null
+++ b/LibMatrix.Federation/Utilities/UnpaddedBase64.cs
@@ -0,0 +1,17 @@
+namespace LibMatrix.FederationTest.Utilities;
+
+public static class UnpaddedBase64 {
+ public static string Encode(byte[] data) {
+ return Convert.ToBase64String(data).TrimEnd('=');
+ }
+
+ public static byte[] Decode(string base64) {
+ string paddedBase64 = base64;
+ switch (paddedBase64.Length % 4) {
+ case 2: paddedBase64 += "=="; break;
+ case 3: paddedBase64 += "="; break;
+ }
+
+ return Convert.FromBase64String(paddedBase64);
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix.Federation/XMatrixAuthorizationScheme.cs b/LibMatrix.Federation/XMatrixAuthorizationScheme.cs
new file mode 100644
index 0000000..fc402b7
--- /dev/null
+++ b/LibMatrix.Federation/XMatrixAuthorizationScheme.cs
@@ -0,0 +1,71 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+using Microsoft.Extensions.Primitives;
+
+namespace LibMatrix.Federation;
+
+public class XMatrixAuthorizationScheme {
+ public class XMatrixAuthorizationHeader {
+ public const string Scheme = "X-Matrix";
+
+ [JsonPropertyName("origin")]
+ public required string Origin { get; set; }
+
+ [JsonPropertyName("destination")]
+ public required string Destination { get; set; }
+
+ [JsonPropertyName("key")]
+ public required string Key { get; set; }
+
+ [JsonPropertyName("sig")]
+ public required string Signature { get; set; }
+
+ public static XMatrixAuthorizationHeader FromHeaderValue(AuthenticationHeaderValue header) {
+ if (header.Scheme != Scheme)
+ throw new LibMatrixException() {
+ Error = $"Expected authentication scheme of {Scheme}, got {header.Scheme}",
+ ErrorCode = MatrixException.ErrorCodes.M_UNAUTHORIZED
+ };
+
+ if (string.IsNullOrWhiteSpace(header.Parameter))
+ throw new LibMatrixException() {
+ Error = $"Expected authentication header to have a value.",
+ ErrorCode = MatrixException.ErrorCodes.M_UNAUTHORIZED
+ };
+
+ var headerValues = new StringValues(header.Parameter);
+ foreach (var value in headerValues) {
+ Console.WriteLine(headerValues.ToJson());
+ }
+
+ return new() {
+ Destination = "",
+ Key = "",
+ Origin = "",
+ Signature = ""
+ };
+ }
+
+ public string ToHeaderValue() => $"{Scheme} origin=\"{Origin}\", destination=\"{Destination}\", key=\"{Key}\", sig=\"{Signature}\"";
+ }
+
+ public class XMatrixRequestSignature {
+ [JsonPropertyName("method")]
+ public required string Method { get; set; }
+
+ [JsonPropertyName("uri")]
+ public required string Uri { get; set; }
+
+ [JsonPropertyName("origin")]
+ public required string OriginServerName { get; set; }
+
+ [JsonPropertyName("destination")]
+ public required string DestinationServerName { get; set; }
+
+ [JsonPropertyName("content"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public JsonObject? Content { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix.MxApiExtensions/LibMatrix.MxApiExtensions.csproj b/LibMatrix.MxApiExtensions/LibMatrix.MxApiExtensions.csproj
deleted file mode 100644
index 4df0c6b..0000000
--- a/LibMatrix.MxApiExtensions/LibMatrix.MxApiExtensions.csproj
+++ /dev/null
@@ -1,13 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-
- <PropertyGroup>
- <TargetFramework>net8.0</TargetFramework>
- <ImplicitUsings>enable</ImplicitUsings>
- <Nullable>enable</Nullable>
- </PropertyGroup>
-
- <ItemGroup>
- <Folder Include="Classes\" />
- </ItemGroup>
-
-</Project>
diff --git a/LibMatrix.sln b/LibMatrix.sln
index c068216..d6c1c0f 100644
--- a/LibMatrix.sln
+++ b/LibMatrix.sln
@@ -1,114 +1,310 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31903.59
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix", "LibMatrix\LibMatrix.csproj", "{2A07D7DA-7B8F-432D-8AD3-9679B58A7C19}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExampleBots", "ExampleBots", "{840309F0-435B-43A7-8471-8C2BE643889D}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utilities", "Utilities", "{A6345ECE-4C5E-400F-9130-886E343BF314}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.MxApiExtensions", "LibMatrix.MxApiExtensions\LibMatrix.MxApiExtensions.csproj", "{32D9616B-91BB-4B43-97C6-2C3840C12EA6}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.ExampleBot", "ExampleBots\LibMatrix.ExampleBot\LibMatrix.ExampleBot.csproj", "{1B1B2197-61FB-416F-B6C8-845F2E5A0442}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModerationBot", "ExampleBots\ModerationBot\ModerationBot.csproj", "{8F0A820E-F6AE-45A2-970E-7A3759693919}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.DebugDataValidationApi", "Utilities\LibMatrix.DebugDataValidationApi\LibMatrix.DebugDataValidationApi.csproj", "{35DF9A1A-D988-4225-AFA3-06BB8EDEB559}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Utilities.Bot", "Utilities\LibMatrix.Utilities.Bot\LibMatrix.Utilities.Bot.csproj", "{3ACF2613-E23F-42C2-925E-0BB4FC3AB1F7}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralContactBotPoC", "ExampleBots\PluralContactBotPoC\PluralContactBotPoC.csproj", "{FB8FE4EB-B53B-464B-A5FD-9BF9D0F3EF9B}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{BFE16D8E-EFC5-49F6-9854-DB001309B3B4}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Tests", "Tests\LibMatrix.Tests\LibMatrix.Tests.csproj", "{345934FF-CA81-4A4B-B137-9F198102C65F}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestDataGenerator", "Tests\TestDataGenerator\TestDataGenerator.csproj", "{0B9B34D1-9362-45A9-9C21-816FD6959110}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.JsonSerializerContextGenerator", "Utilities\LibMatrix.JsonSerializerContextGenerator\LibMatrix.JsonSerializerContextGenerator.csproj", "{4D9B5227-48DC-4A30-9263-AFB51DC01ABB}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ArcaneLibs", "ArcaneLibs", "{01A126FE-9D50-40F2-817B-E55F4065EA76}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs", "ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj", "{13A797D1-7E13-4789-A167-8628B1641AC0}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.EventTypes", "LibMatrix.EventTypes\LibMatrix.EventTypes.csproj", "{CD13665B-B964-4AB0-991B-12F067B16DA3}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.HomeserverEmulator", "Tests\LibMatrix.HomeserverEmulator\LibMatrix.HomeserverEmulator.csproj", "{D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {2A07D7DA-7B8F-432D-8AD3-9679B58A7C19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {2A07D7DA-7B8F-432D-8AD3-9679B58A7C19}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {2A07D7DA-7B8F-432D-8AD3-9679B58A7C19}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {2A07D7DA-7B8F-432D-8AD3-9679B58A7C19}.Release|Any CPU.Build.0 = Release|Any CPU
- {32D9616B-91BB-4B43-97C6-2C3840C12EA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {32D9616B-91BB-4B43-97C6-2C3840C12EA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {32D9616B-91BB-4B43-97C6-2C3840C12EA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {32D9616B-91BB-4B43-97C6-2C3840C12EA6}.Release|Any CPU.Build.0 = Release|Any CPU
- {1B1B2197-61FB-416F-B6C8-845F2E5A0442}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {1B1B2197-61FB-416F-B6C8-845F2E5A0442}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {1B1B2197-61FB-416F-B6C8-845F2E5A0442}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {1B1B2197-61FB-416F-B6C8-845F2E5A0442}.Release|Any CPU.Build.0 = Release|Any CPU
- {8F0A820E-F6AE-45A2-970E-7A3759693919}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {8F0A820E-F6AE-45A2-970E-7A3759693919}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {8F0A820E-F6AE-45A2-970E-7A3759693919}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {8F0A820E-F6AE-45A2-970E-7A3759693919}.Release|Any CPU.Build.0 = Release|Any CPU
- {35DF9A1A-D988-4225-AFA3-06BB8EDEB559}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {35DF9A1A-D988-4225-AFA3-06BB8EDEB559}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {35DF9A1A-D988-4225-AFA3-06BB8EDEB559}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {35DF9A1A-D988-4225-AFA3-06BB8EDEB559}.Release|Any CPU.Build.0 = Release|Any CPU
- {3ACF2613-E23F-42C2-925E-0BB4FC3AB1F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {3ACF2613-E23F-42C2-925E-0BB4FC3AB1F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {3ACF2613-E23F-42C2-925E-0BB4FC3AB1F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {3ACF2613-E23F-42C2-925E-0BB4FC3AB1F7}.Release|Any CPU.Build.0 = Release|Any CPU
- {FB8FE4EB-B53B-464B-A5FD-9BF9D0F3EF9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {FB8FE4EB-B53B-464B-A5FD-9BF9D0F3EF9B}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {FB8FE4EB-B53B-464B-A5FD-9BF9D0F3EF9B}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {FB8FE4EB-B53B-464B-A5FD-9BF9D0F3EF9B}.Release|Any CPU.Build.0 = Release|Any CPU
- {345934FF-CA81-4A4B-B137-9F198102C65F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {345934FF-CA81-4A4B-B137-9F198102C65F}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {345934FF-CA81-4A4B-B137-9F198102C65F}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {345934FF-CA81-4A4B-B137-9F198102C65F}.Release|Any CPU.Build.0 = Release|Any CPU
- {0B9B34D1-9362-45A9-9C21-816FD6959110}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {0B9B34D1-9362-45A9-9C21-816FD6959110}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {0B9B34D1-9362-45A9-9C21-816FD6959110}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {0B9B34D1-9362-45A9-9C21-816FD6959110}.Release|Any CPU.Build.0 = Release|Any CPU
- {4D9B5227-48DC-4A30-9263-AFB51DC01ABB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {4D9B5227-48DC-4A30-9263-AFB51DC01ABB}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {4D9B5227-48DC-4A30-9263-AFB51DC01ABB}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {4D9B5227-48DC-4A30-9263-AFB51DC01ABB}.Release|Any CPU.Build.0 = Release|Any CPU
- {13A797D1-7E13-4789-A167-8628B1641AC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {13A797D1-7E13-4789-A167-8628B1641AC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {13A797D1-7E13-4789-A167-8628B1641AC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {13A797D1-7E13-4789-A167-8628B1641AC0}.Release|Any CPU.Build.0 = Release|Any CPU
- {CD13665B-B964-4AB0-991B-12F067B16DA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {CD13665B-B964-4AB0-991B-12F067B16DA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {CD13665B-B964-4AB0-991B-12F067B16DA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {CD13665B-B964-4AB0-991B-12F067B16DA3}.Release|Any CPU.Build.0 = Release|Any CPU
- {D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D44DB78D-9BAD-4AB6-A054-839ECA9D68D2}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {1B1B2197-61FB-416F-B6C8-845F2E5A0442} = {840309F0-435B-43A7-8471-8C2BE643889D}
- {8F0A820E-F6AE-45A2-970E-7A3759693919} = {840309F0-435B-43A7-8471-8C2BE643889D}
- {35DF9A1A-D988-4225-AFA3-06BB8EDEB559} = {A6345ECE-4C5E-400F-9130-886E343BF314}
- {3ACF2613-E23F-42C2-925E-0BB4FC3AB1F7} = {A6345ECE-4C5E-400F-9130-886E343BF314}
- {FB8FE4EB-B53B-464B-A5FD-9BF9D0F3EF9B} = {840309F0-435B-43A7-8471-8C2BE643889D}
- {345934FF-CA81-4A4B-B137-9F198102C65F} = {BFE16D8E-EFC5-49F6-9854-DB001309B3B4}
- {0B9B34D1-9362-45A9-9C21-816FD6959110} = {BFE16D8E-EFC5-49F6-9854-DB001309B3B4}
- {4D9B5227-48DC-4A30-9263-AFB51DC01ABB} = {A6345ECE-4C5E-400F-9130-886E343BF314}
- {13A797D1-7E13-4789-A167-8628B1641AC0} = {01A126FE-9D50-40F2-817B-E55F4065EA76}
- {D44DB78D-9BAD-4AB6-A054-839ECA9D68D2} = {BFE16D8E-EFC5-49F6-9854-DB001309B3B4}
- EndGlobalSection
-EndGlobal
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ArcaneLibs", "ArcaneLibs", "{32C0F9AC-AF7D-4476-A269-99ACA000EF9F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Blazor.Components", "ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj", "{93D00F03-02FF-4C2A-8917-6863D6E633D9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Legacy", "ArcaneLibs\ArcaneLibs.Legacy\ArcaneLibs.Legacy.csproj", "{14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Logging", "ArcaneLibs\ArcaneLibs.Logging\ArcaneLibs.Logging.csproj", "{51F770AF-C0C6-4247-A358-82DF323F473D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.StringNormalisation", "ArcaneLibs\ArcaneLibs.StringNormalisation\ArcaneLibs.StringNormalisation.csproj", "{8A8E8B67-9351-471E-A502-AF7FAE596CB4}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Tests", "ArcaneLibs\ArcaneLibs.Tests\ArcaneLibs.Tests.csproj", "{5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Timings", "ArcaneLibs\ArcaneLibs.Timings\ArcaneLibs.Timings.csproj", "{567DABFB-611E-4779-9F39-1D4A5B8F0247}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.UsageTest", "ArcaneLibs\ArcaneLibs.UsageTest\ArcaneLibs.UsageTest.csproj", "{28AFDFEF-7597-4450-B999-87C22C24DCD8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs", "ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj", "{F80D5395-28E3-4C34-9662-A890A215DDA2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.EventTypes", "LibMatrix.EventTypes\LibMatrix.EventTypes.csproj", "{90A38896-993A-49F1-903B-8C989D8C9B3A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix", "LibMatrix\LibMatrix.csproj", "{6AE504F8-734B-456B-8BEA-DE37639CB3A7}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{AAAA5609-D8C1-401E-BB5C-724EFE3955FB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Tests", "Tests\LibMatrix.Tests\LibMatrix.Tests.csproj", "{7E15D295-B938-409C-98F6-A22ABC7F3005}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utilities", "Utilities", "{FBC6F613-4E0B-4A90-8854-31887A796EEF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.DebugDataValidationApi", "Utilities\LibMatrix.DebugDataValidationApi\LibMatrix.DebugDataValidationApi.csproj", "{D632374A-70A1-4954-8F2D-C0B511A24426}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.DevTestBot", "Utilities\LibMatrix.DevTestBot\LibMatrix.DevTestBot.csproj", "{91E41EE3-687B-4CFD-94C4-028D09BACFAB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.E2eeTestKit", "Utilities\LibMatrix.E2eeTestKit\LibMatrix.E2eeTestKit.csproj", "{4D411F2D-A97D-4485-A318-ED98B96B6CF6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.HomeserverEmulator", "Utilities\LibMatrix.HomeserverEmulator\LibMatrix.HomeserverEmulator.csproj", "{7F36D200-FE91-4761-B681-BEE091FB953F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.JsonSerializerContextGenerator", "Utilities\LibMatrix.JsonSerializerContextGenerator\LibMatrix.JsonSerializerContextGenerator.csproj", "{69CCB780-50CC-4E75-B794-932E44ACA80F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.TestDataGenerator", "Utilities\LibMatrix.TestDataGenerator\LibMatrix.TestDataGenerator.csproj", "{C087F8AC-B438-4980-9A79-1E16D0CFB67A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Utilities.Bot", "Utilities\LibMatrix.Utilities.Bot\LibMatrix.Utilities.Bot.csproj", "{E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Federation", "LibMatrix.Federation\LibMatrix.Federation.csproj", "{0E6CF267-14FD-43D7-81CB-EE020FD1D106}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Debug|x64.Build.0 = Debug|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Debug|x86.Build.0 = Debug|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Release|x64.ActiveCfg = Release|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Release|x64.Build.0 = Release|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Release|x86.ActiveCfg = Release|Any CPU
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9}.Release|x86.Build.0 = Release|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Debug|x64.Build.0 = Debug|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Debug|x86.Build.0 = Debug|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Release|x64.ActiveCfg = Release|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Release|x64.Build.0 = Release|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Release|x86.ActiveCfg = Release|Any CPU
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB}.Release|x86.Build.0 = Release|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Debug|x64.Build.0 = Debug|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Debug|x86.Build.0 = Debug|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Release|x64.ActiveCfg = Release|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Release|x64.Build.0 = Release|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Release|x86.ActiveCfg = Release|Any CPU
+ {51F770AF-C0C6-4247-A358-82DF323F473D}.Release|x86.Build.0 = Release|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Debug|x64.Build.0 = Debug|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Debug|x86.Build.0 = Debug|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Release|x64.ActiveCfg = Release|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Release|x64.Build.0 = Release|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Release|x86.ActiveCfg = Release|Any CPU
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4}.Release|x86.Build.0 = Release|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Debug|x64.Build.0 = Debug|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Debug|x86.Build.0 = Debug|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Release|x64.ActiveCfg = Release|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Release|x64.Build.0 = Release|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Release|x86.ActiveCfg = Release|Any CPU
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB}.Release|x86.Build.0 = Release|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Debug|x64.Build.0 = Debug|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Debug|x86.Build.0 = Debug|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Release|Any CPU.Build.0 = Release|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Release|x64.ActiveCfg = Release|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Release|x64.Build.0 = Release|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Release|x86.ActiveCfg = Release|Any CPU
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247}.Release|x86.Build.0 = Release|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Debug|x64.Build.0 = Debug|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Debug|x86.Build.0 = Debug|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Release|x64.ActiveCfg = Release|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Release|x64.Build.0 = Release|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Release|x86.ActiveCfg = Release|Any CPU
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8}.Release|x86.Build.0 = Release|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Debug|x64.Build.0 = Debug|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Debug|x86.Build.0 = Debug|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Release|x64.ActiveCfg = Release|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Release|x64.Build.0 = Release|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Release|x86.ActiveCfg = Release|Any CPU
+ {F80D5395-28E3-4C34-9662-A890A215DDA2}.Release|x86.Build.0 = Release|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Debug|x64.Build.0 = Debug|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Debug|x86.Build.0 = Debug|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Release|x64.ActiveCfg = Release|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Release|x64.Build.0 = Release|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Release|x86.ActiveCfg = Release|Any CPU
+ {90A38896-993A-49F1-903B-8C989D8C9B3A}.Release|x86.Build.0 = Release|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Debug|x64.Build.0 = Debug|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Debug|x86.Build.0 = Debug|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Release|x64.ActiveCfg = Release|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Release|x64.Build.0 = Release|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Release|x86.ActiveCfg = Release|Any CPU
+ {6AE504F8-734B-456B-8BEA-DE37639CB3A7}.Release|x86.Build.0 = Release|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Debug|x64.Build.0 = Debug|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Debug|x86.Build.0 = Debug|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Release|x64.ActiveCfg = Release|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Release|x64.Build.0 = Release|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Release|x86.ActiveCfg = Release|Any CPU
+ {7E15D295-B938-409C-98F6-A22ABC7F3005}.Release|x86.Build.0 = Release|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Debug|x64.Build.0 = Debug|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Debug|x86.Build.0 = Debug|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Release|x64.ActiveCfg = Release|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Release|x64.Build.0 = Release|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Release|x86.ActiveCfg = Release|Any CPU
+ {D632374A-70A1-4954-8F2D-C0B511A24426}.Release|x86.Build.0 = Release|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Debug|x64.Build.0 = Debug|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Debug|x86.Build.0 = Debug|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Release|x64.ActiveCfg = Release|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Release|x64.Build.0 = Release|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Release|x86.ActiveCfg = Release|Any CPU
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB}.Release|x86.Build.0 = Release|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Debug|x64.Build.0 = Debug|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Debug|x86.Build.0 = Debug|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Release|x64.ActiveCfg = Release|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Release|x64.Build.0 = Release|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Release|x86.ActiveCfg = Release|Any CPU
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6}.Release|x86.Build.0 = Release|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Debug|x64.Build.0 = Debug|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Debug|x86.Build.0 = Debug|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Release|x64.ActiveCfg = Release|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Release|x64.Build.0 = Release|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Release|x86.ActiveCfg = Release|Any CPU
+ {7F36D200-FE91-4761-B681-BEE091FB953F}.Release|x86.Build.0 = Release|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Debug|x64.Build.0 = Debug|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Debug|x86.Build.0 = Debug|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Release|x64.ActiveCfg = Release|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Release|x64.Build.0 = Release|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Release|x86.ActiveCfg = Release|Any CPU
+ {69CCB780-50CC-4E75-B794-932E44ACA80F}.Release|x86.Build.0 = Release|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Debug|x64.Build.0 = Debug|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Debug|x86.Build.0 = Debug|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Release|x64.ActiveCfg = Release|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Release|x64.Build.0 = Release|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Release|x86.ActiveCfg = Release|Any CPU
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A}.Release|x86.Build.0 = Release|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Debug|x64.Build.0 = Debug|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Debug|x86.Build.0 = Debug|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Release|x64.ActiveCfg = Release|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Release|x64.Build.0 = Release|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Release|x86.ActiveCfg = Release|Any CPU
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A}.Release|x86.Build.0 = Release|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Debug|x64.Build.0 = Debug|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Debug|x86.Build.0 = Debug|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Release|x64.ActiveCfg = Release|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Release|x64.Build.0 = Release|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Release|x86.ActiveCfg = Release|Any CPU
+ {0E6CF267-14FD-43D7-81CB-EE020FD1D106}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {93D00F03-02FF-4C2A-8917-6863D6E633D9} = {32C0F9AC-AF7D-4476-A269-99ACA000EF9F}
+ {14CAF7A1-A5C3-4ED1-8BF3-20FC3447BFFB} = {32C0F9AC-AF7D-4476-A269-99ACA000EF9F}
+ {51F770AF-C0C6-4247-A358-82DF323F473D} = {32C0F9AC-AF7D-4476-A269-99ACA000EF9F}
+ {8A8E8B67-9351-471E-A502-AF7FAE596CB4} = {32C0F9AC-AF7D-4476-A269-99ACA000EF9F}
+ {5DE8D878-EEBD-44DF-A8FC-0B4866B807FB} = {32C0F9AC-AF7D-4476-A269-99ACA000EF9F}
+ {567DABFB-611E-4779-9F39-1D4A5B8F0247} = {32C0F9AC-AF7D-4476-A269-99ACA000EF9F}
+ {28AFDFEF-7597-4450-B999-87C22C24DCD8} = {32C0F9AC-AF7D-4476-A269-99ACA000EF9F}
+ {F80D5395-28E3-4C34-9662-A890A215DDA2} = {32C0F9AC-AF7D-4476-A269-99ACA000EF9F}
+ {7E15D295-B938-409C-98F6-A22ABC7F3005} = {AAAA5609-D8C1-401E-BB5C-724EFE3955FB}
+ {D632374A-70A1-4954-8F2D-C0B511A24426} = {FBC6F613-4E0B-4A90-8854-31887A796EEF}
+ {91E41EE3-687B-4CFD-94C4-028D09BACFAB} = {FBC6F613-4E0B-4A90-8854-31887A796EEF}
+ {4D411F2D-A97D-4485-A318-ED98B96B6CF6} = {FBC6F613-4E0B-4A90-8854-31887A796EEF}
+ {7F36D200-FE91-4761-B681-BEE091FB953F} = {FBC6F613-4E0B-4A90-8854-31887A796EEF}
+ {69CCB780-50CC-4E75-B794-932E44ACA80F} = {FBC6F613-4E0B-4A90-8854-31887A796EEF}
+ {C087F8AC-B438-4980-9A79-1E16D0CFB67A} = {FBC6F613-4E0B-4A90-8854-31887A796EEF}
+ {E04E5F37-8F13-4000-ABA1-DAC0D795EA6A} = {FBC6F613-4E0B-4A90-8854-31887A796EEF}
+ EndGlobalSection
+EndGlobal
diff --git a/LibMatrix/Extensions/CanonicalJsonSerializer.cs b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
new file mode 100644
index 0000000..ae535aa
--- /dev/null
+++ b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
@@ -0,0 +1,96 @@
+using System.Collections.Frozen;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization.Metadata;
+using ArcaneLibs.Extensions;
+
+namespace LibMatrix.Extensions;
+
+public static class CanonicalJsonSerializer {
+ // TODO: Alphabetise dictionaries
+ private static JsonSerializerOptions JsonOptions => new() {
+ WriteIndented = false,
+ Encoder = UnicodeJsonEncoder.Singleton,
+ };
+
+ private static readonly FrozenSet<PropertyInfo> JsonSerializerOptionsProperties = typeof(JsonSerializerOptions)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(x => x.SetMethod != null && x.GetMethod != null)
+ .ToFrozenSet();
+
+ private static JsonSerializerOptions MergeOptions(JsonSerializerOptions? inputOptions) {
+ var newOptions = JsonOptions;
+ if (inputOptions == null)
+ return newOptions;
+
+ foreach (var property in JsonSerializerOptionsProperties) {
+ if(property.Name == nameof(JsonSerializerOptions.Encoder))
+ continue;
+ if (property.Name == nameof(JsonSerializerOptions.WriteIndented))
+ continue;
+
+ var value = property.GetValue(inputOptions);
+ // if (value == null)
+ // continue;
+ property.SetValue(newOptions, value);
+ }
+
+ return newOptions;
+ }
+
+#region STJ API
+
+ public static String Serialize<TValue>(TValue value, JsonSerializerOptions? options = null) {
+ var newOptions = MergeOptions(options);
+
+ return JsonSerializer.SerializeToNode(value, options) // We want to allow passing custom converters for eg. double/float -> string here...
+ .SortProperties()!
+ .CanonicalizeNumbers()!
+ .ToJsonString(newOptions);
+
+
+ // System.Text.Json.JsonSerializer.SerializeToNode(System.Text.Json.JsonSerializer.Deserialize<dynamic>("{\n \"a\": -0,\n \"b\": 1e10\n}")).ToJsonString();
+
+ }
+
+ public static String Serialize(object value, Type inputType, JsonSerializerOptions? options = null) => JsonSerializer.Serialize(value, inputType, JsonOptions);
+ // public static String Serialize<TValue>(TValue value, JsonTypeInfo<TValue> jsonTypeInfo) => JsonSerializer.Serialize(value, jsonTypeInfo, _options);
+ // public static String Serialize(Object value, JsonTypeInfo jsonTypeInfo)
+
+ public static byte[] SerializeToUtf8Bytes<T>(T value, JsonSerializerOptions? options = null) {
+ var newOptions = MergeOptions(null);
+ return JsonSerializer.SerializeToNode(value, options) // We want to allow passing custom converters for eg. double/float -> string here...
+ .SortProperties()!
+ .CanonicalizeNumbers()!
+ .ToJsonString(newOptions).AsBytes().ToArray();
+ }
+
+#endregion
+
+ // ReSharper disable once UnusedType.Local
+ private static class JsonExtensions {
+ public static Action<JsonTypeInfo> AlphabetizeProperties(Type type) {
+ return typeInfo => {
+ if (typeInfo.Kind != JsonTypeInfoKind.Object || !type.IsAssignableFrom(typeInfo.Type))
+ return;
+ AlphabetizeProperties()(typeInfo);
+ };
+ }
+
+ public static Action<JsonTypeInfo> AlphabetizeProperties() {
+ return static typeInfo => {
+ if (typeInfo.Kind == JsonTypeInfoKind.Dictionary) { }
+
+ if (typeInfo.Kind != JsonTypeInfoKind.Object)
+ return;
+ var properties = typeInfo.Properties.OrderBy(p => p.Name, StringComparer.Ordinal).ToList();
+ typeInfo.Properties.Clear();
+ for (int i = 0; i < properties.Count; i++) {
+ properties[i].Order = i;
+ typeInfo.Properties.Add(properties[i]);
+ }
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Extensions/EnumerableExtensions.cs b/LibMatrix/Extensions/EnumerableExtensions.cs
index 42d9491..4dcf26e 100644
--- a/LibMatrix/Extensions/EnumerableExtensions.cs
+++ b/LibMatrix/Extensions/EnumerableExtensions.cs
@@ -1,29 +1,86 @@
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+
namespace LibMatrix.Extensions;
public static class EnumerableExtensions {
public static void MergeStateEventLists(this IList<StateEvent> oldState, IList<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;
+ // 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);
+ // }
+
+ foreach (var e in newState) {
+ switch (FindIndex(e)) {
+ case -1:
+ oldState.Add(e);
+ break;
+ case var index:
+ oldState[index] = e;
+ break;
+ }
+ }
+
+ int FindIndex(StateEvent needle) {
+ for (int i = 0; i < oldState.Count; i++) {
+ var old = oldState[i];
+ if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+ return i;
}
- oldState.Remove(old);
- oldState.Add(stateEvent);
+ return -1;
}
}
public static void MergeStateEventLists(this IList<StateEventResponse> oldState, IList<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;
+ foreach (var e in newState) {
+ switch (FindIndex(e)) {
+ case -1:
+ oldState.Add(e);
+ break;
+ case var index:
+ oldState[index] = e;
+ break;
+ }
+ }
+
+ int FindIndex(StateEventResponse needle) {
+ for (int i = 0; i < oldState.Count; i++) {
+ var old = oldState[i];
+ if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+ return i;
+ }
+
+ return -1;
+ }
+ }
+
+ public static void MergeStateEventLists(this List<StateEventResponse> oldState, List<StateEventResponse> newState) {
+ foreach (var e in newState) {
+ switch (FindIndex(e)) {
+ case -1:
+ oldState.Add(e);
+ break;
+ case var index:
+ oldState[index] = e;
+ break;
+ }
+ }
+
+ int FindIndex(StateEventResponse needle) {
+ for (int i = 0; i < oldState.Count; i++) {
+ var old = oldState[i];
+ if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+ return i;
}
- oldState.Remove(old);
- oldState.Add(stateEvent);
+ return -1;
}
}
}
\ No newline at end of file
diff --git a/LibMatrix/Extensions/JsonElementExtensions.cs b/LibMatrix/Extensions/JsonElementExtensions.cs
index c4ed743..dfec95b 100644
--- a/LibMatrix/Extensions/JsonElementExtensions.cs
+++ b/LibMatrix/Extensions/JsonElementExtensions.cs
@@ -126,6 +126,7 @@ public static class JsonElementExtensions {
$"Encountered dictionary {field.Name} with key type {keyType.Name} and value type {valueType.Name}!");
return field.Value.EnumerateObject()
+ // TODO: use key.Value?
.Where(key => !valueType.IsPrimitive && valueType != typeof(string))
.Aggregate(false, (current, key) =>
current | key.FindExtraJsonPropertyFieldsByValueKind(containerType, valueType)
diff --git a/LibMatrix/Extensions/MatrixHttpClient.Multi.cs b/LibMatrix/Extensions/MatrixHttpClient.Multi.cs
index e7a2044..bf6fe63 100644
--- a/LibMatrix/Extensions/MatrixHttpClient.Multi.cs
+++ b/LibMatrix/Extensions/MatrixHttpClient.Multi.cs
@@ -1,16 +1,5 @@
#define SINGLE_HTTPCLIENT // Use a single HttpClient instance for all MatrixHttpClient instances
// #define SYNC_HTTPCLIENT // Only allow one request as a time, for debugging
-using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
-using System.Net.Http.Headers;
-using System.Reflection;
-using System.Security.Cryptography.X509Certificates;
-using System.Text;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using ArcaneLibs;
-using ArcaneLibs.Extensions;
-
namespace LibMatrix.Extensions;
public static class HttpClientExtensions {
diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
index 39eb7e5..671566f 100644
--- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs
+++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
@@ -1,20 +1,21 @@
#define SINGLE_HTTPCLIENT // Use a single HttpClient instance for all MatrixHttpClient instances
-// #define SYNC_HTTPCLIENT // Only allow one request as a time, for debugging
+// #define SYNC_HTTPCLIENT // Only allow one request as a time, for debugging
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
+using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
-using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ArcaneLibs;
using ArcaneLibs.Extensions;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
namespace LibMatrix.Extensions;
#if SINGLE_HTTPCLIENT
-// TODO: Add URI wrapper for
+// TODO: Add URI wrapper for
public class MatrixHttpClient {
private static readonly HttpClient Client;
@@ -50,6 +51,7 @@ public class MatrixHttpClient {
internal SemaphoreSlim _rateLimitSemaphore { get; } = new(1, 1);
#endif
+ public static bool LogRequests = true;
public Dictionary<string, string> AdditionalQueryParameters { get; set; } = new();
public Uri? BaseAddress { get; set; }
@@ -59,7 +61,7 @@ public class MatrixHttpClient {
typeof(HttpRequestHeaders).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, [], null)?.Invoke([]) as HttpRequestHeaders ??
throw new InvalidOperationException("Failed to create HttpRequestHeaders");
- private JsonSerializerOptions GetJsonSerializerOptions(JsonSerializerOptions? options = null) {
+ private static JsonSerializerOptions GetJsonSerializerOptions(JsonSerializerOptions? options = null) {
options ??= new JsonSerializerOptions();
options.Converters.Add(new JsonFloatStringConverter());
options.Converters.Add(new JsonDoubleStringConverter());
@@ -68,27 +70,54 @@ public class MatrixHttpClient {
return options;
}
- public async Task<HttpResponseMessage> SendUnhandledAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
+ public async Task<HttpResponseMessage> SendUnhandledAsync(HttpRequestMessage request, CancellationToken cancellationToken, int attempt = 0) {
+ if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
+ // if (!request.RequestUri.IsAbsoluteUri)
+ request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!);
+ var swWait = Stopwatch.StartNew();
#if SYNC_HTTPCLIENT
await _rateLimitSemaphore.WaitAsync(cancellationToken);
#endif
- Console.WriteLine($"Sending {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)})");
-
if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
- if (!request.RequestUri.IsAbsoluteUri) request.RequestUri = new Uri(BaseAddress, request.RequestUri);
+ if (!request.RequestUri.IsAbsoluteUri)
+ request.RequestUri = new Uri(BaseAddress ?? throw new InvalidOperationException("Relative URI passed, but no BaseAddress is specified!"), request.RequestUri);
+ swWait.Stop();
+ var swExec = Stopwatch.StartNew();
+
foreach (var (key, value) in AdditionalQueryParameters) request.RequestUri = request.RequestUri.AddQuery(key, value);
- foreach (var (key, value) in DefaultRequestHeaders) request.Headers.Add(key, value);
+ foreach (var (key, value) in DefaultRequestHeaders) {
+ if (request.Headers.Contains(key)) continue;
+ request.Headers.Add(key, value);
+ }
request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true);
+ if (LogRequests)
+ Console.WriteLine("Sending " + request.Summarise(includeHeaders: true, includeQuery: true, includeContentIfText: false, hideHeaders: ["Accept"]));
+
HttpResponseMessage? responseMessage;
try {
responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}
catch (Exception e) {
- Console.WriteLine(
- $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
+ // if (attempt >= 5) {
+ // Console.WriteLine(
+ // $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
+ // throw;
+ // }
+ //
+ // if (e is TaskCanceledException or TimeoutException or HttpRequestException) {
+ // if (request.Method == HttpMethod.Get && !cancellationToken.IsCancellationRequested) {
+ // await Task.Delay(Random.Shared.Next(100, 1000), cancellationToken);
+ // request.ResetSendStatus();
+ // return await SendUnhandledAsync(request, cancellationToken, attempt + 1);
+ // }
+ // }
+ // else if (!e.ToString().StartsWith("TypeError: NetworkError"))
+ // Console.WriteLine(
+ // $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
+
throw;
}
#if SYNC_HTTPCLIENT
@@ -97,8 +126,26 @@ public class MatrixHttpClient {
}
#endif
- Console.WriteLine(
- $"Sending {request.Method} {request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}) -> {(int)responseMessage.StatusCode} {responseMessage.StatusCode} ({Util.BytesToString(responseMessage.Content.Headers.ContentLength ?? 0)})");
+ // Console.WriteLine($"Sending {request.Method} {request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}) -> {(int)responseMessage.StatusCode} {responseMessage.StatusCode} ({Util.BytesToString(responseMessage.GetContentLength())}, WAIT={swWait.ElapsedMilliseconds}ms, EXEC={swExec.ElapsedMilliseconds}ms)");
+ if (LogRequests)
+ Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [
+ "Server",
+ "Date",
+ "Transfer-Encoding",
+ "Connection",
+ "Vary",
+ "Content-Length",
+ "Access-Control-Allow-Origin",
+ "Access-Control-Allow-Methods",
+ "Access-Control-Allow-Headers",
+ "Access-Control-Expose-Headers",
+ "Cache-Control",
+ "Cross-Origin-Resource-Policy",
+ "X-Content-Security-Policy",
+ "Referrer-Policy",
+ "X-Robots-Tag",
+ "Content-Security-Policy"
+ ]));
return responseMessage;
}
@@ -107,6 +154,12 @@ public class MatrixHttpClient {
var responseMessage = await SendUnhandledAsync(request, cancellationToken);
if (responseMessage.IsSuccessStatusCode) return responseMessage;
+ //retry on gateway timeout
+ // if (responseMessage.StatusCode == HttpStatusCode.GatewayTimeout) {
+ // request.ResetSendStatus();
+ // return await SendAsync(request, cancellationToken);
+ // }
+
//error handling
var content = await responseMessage.Content.ReadAsStringAsync(cancellationToken);
if (content.Length == 0)
@@ -114,10 +167,15 @@ public class MatrixHttpClient {
ErrorCode = "M_UNKNOWN",
Error = "Unknown error, server returned no content"
};
- if (!content.StartsWith('{')) throw new InvalidDataException("Encountered invalid data:\n" + content);
+
+ // if (!content.StartsWith('{')) throw new InvalidDataException("Encountered invalid data:\n" + content);
+ if (!content.TrimStart().StartsWith('{')) {
+ responseMessage.EnsureSuccessStatusCode();
+ throw new InvalidDataException("Encountered invalid data:\n" + content);
+ }
//we have a matrix error
- MatrixException? ex = null;
+ MatrixException? ex;
try {
ex = JsonSerializer.Deserialize<MatrixException>(content);
}
@@ -131,7 +189,7 @@ public class MatrixHttpClient {
Debug.Assert(ex != null, nameof(ex) + " != null");
ex.RawContent = content;
// Console.WriteLine($"Failed to send request: {ex}");
- if (ex?.RetryAfterMs is null) throw ex!;
+ if (ex.RetryAfterMs is null) throw ex!;
//we have a ratelimit error
await Task.Delay(ex.RetryAfterMs.Value, cancellationToken);
request.ResetSendStatus();
@@ -170,7 +228,7 @@ public class MatrixHttpClient {
}
// GetStreamAsync
- public new async Task<Stream> GetStreamAsync(string requestUri, CancellationToken cancellationToken = default) {
+ public async Task<Stream> GetStreamAsync(string requestUri, CancellationToken cancellationToken = default) {
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await SendAsync(request, cancellationToken);
@@ -209,7 +267,7 @@ public class MatrixHttpClient {
await foreach (var resp in result) yield return resp;
}
- public async Task<bool> CheckSuccessStatus(string url) {
+ public static async Task<bool> CheckSuccessStatus(string url) {
//cors causes failure, try to catch
try {
var resp = await Client.GetAsync(url);
@@ -227,5 +285,17 @@ public class MatrixHttpClient {
};
return await SendAsync(request, cancellationToken);
}
+
+ public async Task<HttpResponseMessage> DeleteAsync(string url) {
+ var request = new HttpRequestMessage(HttpMethod.Delete, url);
+ return await SendAsync(request);
+ }
+
+ public async Task<HttpResponseMessage> DeleteAsJsonAsync<T>(string url, T payload) {
+ var request = new HttpRequestMessage(HttpMethod.Delete, url) {
+ Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
+ };
+ return await SendAsync(request);
+ }
}
#endif
\ No newline at end of file
diff --git a/LibMatrix/Extensions/UnicodeJsonEncoder.cs b/LibMatrix/Extensions/UnicodeJsonEncoder.cs
new file mode 100644
index 0000000..ae58263
--- /dev/null
+++ b/LibMatrix/Extensions/UnicodeJsonEncoder.cs
@@ -0,0 +1,173 @@
+// LibMatrix: File sourced from https://github.com/dotnet/runtime/pull/87147/files under the MIT license.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+using System.Text.Encodings.Web;
+
+namespace LibMatrix.Extensions;
+
+internal sealed class UnicodeJsonEncoder : JavaScriptEncoder
+{
+ internal static readonly UnicodeJsonEncoder Singleton = new UnicodeJsonEncoder();
+
+ private readonly bool _preferHexEscape;
+ private readonly bool _preferUppercase;
+
+ public UnicodeJsonEncoder()
+ : this(preferHexEscape: false, preferUppercase: false)
+ {
+ }
+
+ public UnicodeJsonEncoder(bool preferHexEscape, bool preferUppercase)
+ {
+ _preferHexEscape = preferHexEscape;
+ _preferUppercase = preferUppercase;
+ }
+
+ public override int MaxOutputCharactersPerInputCharacter => 6; // "\uXXXX" for a single char ("\uXXXX\uYYYY" [12 chars] for supplementary scalar value)
+
+ public override unsafe int FindFirstCharacterToEncode(char* text, int textLength)
+ {
+ for (int index = 0; index < textLength; ++index)
+ {
+ char value = text[index];
+
+ if (NeedsEncoding(value))
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ public override unsafe bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten)
+ {
+ bool encode = WillEncode(unicodeScalar);
+
+ if (!encode)
+ {
+ Span<char> span = new Span<char>(buffer, bufferLength);
+ int spanWritten;
+ bool succeeded = new Rune(unicodeScalar).TryEncodeToUtf16(span, out spanWritten);
+ numberOfCharactersWritten = spanWritten;
+ return succeeded;
+ }
+
+ if (!_preferHexEscape && unicodeScalar <= char.MaxValue && HasTwoCharacterEscape((char)unicodeScalar))
+ {
+ if (bufferLength < 2)
+ {
+ numberOfCharactersWritten = 0;
+ return false;
+ }
+
+ buffer[0] = '\\';
+ buffer[1] = GetTwoCharacterEscapeSuffix((char)unicodeScalar);
+ numberOfCharactersWritten = 2;
+ return true;
+ }
+ else
+ {
+ if (bufferLength < 6)
+ {
+ numberOfCharactersWritten = 0;
+ return false;
+ }
+
+ buffer[0] = '\\';
+ buffer[1] = 'u';
+ buffer[2] = '0';
+ buffer[3] = '0';
+ buffer[4] = ToHexDigit((unicodeScalar & 0xf0) >> 4, _preferUppercase);
+ buffer[5] = ToHexDigit(unicodeScalar & 0xf, _preferUppercase);
+ numberOfCharactersWritten = 6;
+ return true;
+ }
+ }
+
+ public override bool WillEncode(int unicodeScalar)
+ {
+ if (unicodeScalar > char.MaxValue)
+ {
+ return false;
+ }
+
+ return NeedsEncoding((char)unicodeScalar);
+ }
+
+ // https://datatracker.ietf.org/doc/html/rfc8259#section-7
+ private static bool NeedsEncoding(char value)
+ {
+ if (value == '"' || value == '\\')
+ {
+ return true;
+ }
+
+ return value <= '\u001f';
+ }
+
+ private static bool HasTwoCharacterEscape(char value)
+ {
+ // RFC 8259, Section 7, "char = " BNF
+ switch (value)
+ {
+ case '"':
+ case '\\':
+ case '/':
+ case '\b':
+ case '\f':
+ case '\n':
+ case '\r':
+ case '\t':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static char GetTwoCharacterEscapeSuffix(char value)
+ {
+ // RFC 8259, Section 7, "char = " BNF
+ switch (value)
+ {
+ case '"':
+ return '"';
+ case '\\':
+ return '\\';
+ case '/':
+ return '/';
+ case '\b':
+ return 'b';
+ case '\f':
+ return 'f';
+ case '\n':
+ return 'n';
+ case '\r':
+ return 'r';
+ case '\t':
+ return 't';
+ default:
+ throw new ArgumentOutOfRangeException(nameof(value));
+ }
+ }
+
+ private static char ToHexDigit(int value, bool uppercase)
+ {
+ if (value > 0xf)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value));
+ }
+
+ if (value < 10)
+ {
+ return (char)(value + '0');
+ }
+ else
+ {
+ return (char)(value - 0xa + (uppercase ? 'A' : 'a'));
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Filters/SyncFilter.cs b/LibMatrix/Filters/SyncFilter.cs
index 787ffa7..7cb6428 100644
--- a/LibMatrix/Filters/SyncFilter.cs
+++ b/LibMatrix/Filters/SyncFilter.cs
@@ -34,6 +34,15 @@ public class SyncFilter {
[JsonPropertyName("include_leave")]
public bool? IncludeLeave { get; set; }
+ private static RoomFilter Empty => new() {
+ Rooms = [],
+ IncludeLeave = false,
+ AccountData = StateFilter.Empty,
+ Ephemeral = StateFilter.Empty,
+ State = StateFilter.Empty,
+ Timeline = StateFilter.Empty,
+ };
+
public class StateFilter(
bool? containsUrl = null,
bool? includeRedundantMembers = null,
@@ -65,6 +74,9 @@ public class SyncFilter {
[JsonPropertyName("unread_thread_notifications")]
public bool? UnreadThreadNotifications { get; set; } = unreadThreadNotifications;
+
+ // ReSharper disable once MemberHidesStaticFromOuterClass
+ public new static StateFilter Empty => new(limit: 0, senders: [], types: [], rooms: []);
}
}
@@ -83,5 +95,7 @@ public class SyncFilter {
[JsonPropertyName("not_senders")]
public List<string>? NotSenders { get; set; } = notSenders;
+
+ public static EventFilter Empty => new(limit: 0, senders: [], types: []);
}
-}
\ No newline at end of file
+}
diff --git a/LibMatrix/Helpers/HomeserverWeightEstimation.cs b/LibMatrix/Helpers/HomeserverWeightEstimation.cs
deleted file mode 100644
index 5735af3..0000000
--- a/LibMatrix/Helpers/HomeserverWeightEstimation.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-namespace LibMatrix.Helpers;
-
-public class HomeserverWeightEstimation {
- public static Dictionary<string, int> EstimatedSize = new() {
- { "matrix.org", 843870 },
- { "anontier.nl", 44809 },
- { "nixos.org", 8195 },
- { "the-apothecary.club", 6983 },
- { "waifuhunter.club", 3953 },
- { "neko.dev", 2666 },
- { "nerdsin.space", 2647 },
- { "feline.support", 2633 },
- { "gitter.im", 2584 },
- { "midov.pl", 2219 },
- { "no.lgbtqia.zone", 2083 },
- { "nheko.im", 1883 },
- { "fachschaften.org", 1849 },
- { "pixelthefox.net", 1478 },
- { "arcticfoxes.net", 981 },
- { "pixie.town", 817 },
- { "privacyguides.org", 809 },
- { "rory.gay", 653 },
- { "artemislena.eu", 599 },
- { "alchemi.dev", 445 },
- { "jameskitt616.one", 390 },
- { "hackint.org", 382 },
- { "pikaviestin.fi", 368 },
- { "matrix.nomagic.uk", 337 },
- { "thearcanebrony.net", 178 },
- { "fairydust.space", 176 },
- { "grin.hu", 176 },
- { "envs.net", 165 },
- { "tastytea.de", 143 },
- { "koneko.chat", 121 },
- { "vscape.tk", 115 },
- { "funklause.de", 112 },
- { "seirdy.one", 107 },
- { "pcg.life", 72 },
- { "draupnir.midnightthoughts.space", 22 },
- { "tchncs.de", 19 },
- { "catgirl.cloud", 16 },
- { "possum.city", 16 },
- { "tu-dresden.de", 9 },
- { "fosscord.com", 9 },
- { "nightshade.fun", 8 },
- { "matrix.eclipse.org", 8 },
- { "masfloss.net", 8 },
- { "e2e.zone", 8 },
- { "hyteck.de", 8 }
- };
-
- public static Dictionary<string, int> LargeRooms = new() {
- { "!ehXvUhWNASUkSLvAGP:matrix.org", 21957 },
- { "!fRRqjOaQcUbKOfCjvc:anontier.nl", 19117 },
- { "!OGEhHVWSdvArJzumhm:matrix.org", 101457 },
- { "!YTvKGNlinIzlkMTVRl:matrix.org", 30164 }
- };
-}
\ No newline at end of file
diff --git a/LibMatrix/Helpers/MessageBuilder.cs b/LibMatrix/Helpers/MessageBuilder.cs
index d897078..6f55739 100644
--- a/LibMatrix/Helpers/MessageBuilder.cs
+++ b/LibMatrix/Helpers/MessageBuilder.cs
@@ -10,9 +10,9 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr
public RoomMessageEventContent Build() => Content;
- public MessageBuilder WithBody(string body) {
+ public MessageBuilder WithBody(string body, string? formattedBody = null) {
Content.Body += body;
- Content.FormattedBody += body;
+ Content.FormattedBody += formattedBody ?? body;
return this;
}
@@ -37,6 +37,10 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr
return this;
}
+ public static string GetColoredBody(string color, string body) {
+ return $"<font color=\"{color}\">{body}</font>";
+ }
+
public MessageBuilder WithColoredBody(string color, string body) {
Content.Body += body;
Content.FormattedBody += $"<font color=\"{color}\">{body}</font>";
@@ -91,16 +95,38 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr
return this;
}
+ public MessageBuilder WithMention(string id, string? displayName = null) {
+ Content.Body += $"@{displayName ?? id}";
+ Content.FormattedBody += $"<a href=\"https://matrix.to/#/{id}\">{displayName ?? id}</a>";
+ if (id == "@room") {
+ Content.Mentions ??= new();
+ Content.Mentions.Room = true;
+ }
+ else if (id.StartsWith('@')) {
+ Content.Mentions ??= new();
+ Content.Mentions.Users ??= new();
+ Content.Mentions.Users.Add(id);
+ }
+
+ return this;
+ }
+
+ public MessageBuilder WithNewline() {
+ Content.Body += "\n";
+ Content.FormattedBody += "<br>";
+ return this;
+ }
+
public MessageBuilder WithTable(Action<TableBuilder> tableBuilder) {
var tb = new TableBuilder(this);
- this.WithHtmlTag("table", msb => tableBuilder(tb));
+ WithHtmlTag("table", msb => tableBuilder(tb));
return this;
}
public class TableBuilder(MessageBuilder msb) {
public TableBuilder WithTitle(string title, int colspan) {
msb.Content.Body += title + "\n";
- msb.Content.FormattedBody += $"<thead><tr><th colspan=\"{colspan}\">{title}</th></tr></thead>";
+ msb.Content.FormattedBody += $"<thead><tr><th colspan=\"{colspan}\">{title}</th></tr></thead><br/>";
return this;
}
diff --git a/LibMatrix/Helpers/RoomBuilder.cs b/LibMatrix/Helpers/RoomBuilder.cs
new file mode 100644
index 0000000..bef7568
--- /dev/null
+++ b/LibMatrix/Helpers/RoomBuilder.cs
@@ -0,0 +1,173 @@
+using System.Runtime.Intrinsics.X86;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using LibMatrix.RoomTypes;
+
+namespace LibMatrix.Helpers;
+
+public class RoomBuilder {
+ public string? Type { get; set; }
+ public string Version { get; set; } = "11";
+ public RoomNameEventContent Name { get; set; } = new();
+ public RoomTopicEventContent Topic { get; set; } = new();
+ public RoomAvatarEventContent Avatar { get; set; } = new();
+ public RoomCanonicalAliasEventContent CanonicalAlias { get; set; } = new();
+ public string AliasLocalPart { get; set; } = string.Empty;
+ public bool IsFederatable { get; set; } = true;
+ public long OwnPowerLevel { get; set; } = MatrixConstants.MaxSafeJsonInteger;
+
+ public RoomJoinRulesEventContent JoinRules { get; set; } = new() {
+ JoinRule = RoomJoinRulesEventContent.JoinRules.Public
+ };
+
+ public RoomHistoryVisibilityEventContent HistoryVisibility { get; set; } = new() {
+ HistoryVisibility = RoomHistoryVisibilityEventContent.HistoryVisibilityTypes.Shared
+ };
+
+ /// <summary>
+ /// State events to be sent *before* room access is configured. Keep this small!
+ /// </summary>
+ public List<StateEvent> ImportantState { get; set; } = [];
+
+ /// <summary>
+ /// State events to be sent *after* room access is configured, but before invites are sent.
+ /// </summary>
+ public List<StateEvent> InitialState { get; set; } = [];
+
+ /// <summary>
+ /// Users to invite, with optional reason
+ /// </summary>
+ public Dictionary<string, string?> Invites { get; set; } = new();
+
+ public RoomPowerLevelEventContent PowerLevels { get; init; } = new() {
+ EventsDefault = 0,
+ UsersDefault = 0,
+ Kick = 50,
+ Invite = 50,
+ Ban = 50,
+ Redact = 50,
+ StateDefault = 50,
+ NotificationsPl = new() {
+ Room = 50
+ },
+ Users = [],
+ Events = new Dictionary<string, long> {
+ { RoomAvatarEventContent.EventId, 50 },
+ { RoomCanonicalAliasEventContent.EventId, 50 },
+ { RoomEncryptionEventContent.EventId, 100 },
+ { RoomHistoryVisibilityEventContent.EventId, 100 },
+ { RoomNameEventContent.EventId, 50 },
+ { RoomPowerLevelEventContent.EventId, 100 },
+ { RoomServerAclEventContent.EventId, 100 },
+ { RoomTombstoneEventContent.EventId, 100 },
+ { RoomPolicyServerEventContent.EventId, 100 }
+ }
+ };
+
+ public async Task<GenericRoom> Create(AuthenticatedHomeserverGeneric homeserver) {
+ var crq = new CreateRoomRequest() {
+ PowerLevelContentOverride = new() {
+ EventsDefault = 1000000,
+ UsersDefault = 1000000,
+ Kick = 1000000,
+ Invite = 1000000,
+ Ban = 1000000,
+ Redact = 1000000,
+ StateDefault = 1000000,
+ NotificationsPl = new() {
+ Room = 1000000
+ },
+ Users = new Dictionary<string, long>() {
+ { homeserver.WhoAmI.UserId, MatrixConstants.MaxSafeJsonInteger }
+ },
+ Events = new Dictionary<string, long> {
+ { RoomAvatarEventContent.EventId, 1000000 },
+ { RoomCanonicalAliasEventContent.EventId, 1000000 },
+ { RoomEncryptionEventContent.EventId, 1000000 },
+ { RoomHistoryVisibilityEventContent.EventId, 1000000 },
+ { RoomNameEventContent.EventId, 1000000 },
+ { RoomPowerLevelEventContent.EventId, 1000000 },
+ { RoomServerAclEventContent.EventId, 1000000 },
+ { RoomTombstoneEventContent.EventId, 1000000 },
+ { RoomPolicyServerEventContent.EventId, 1000000 }
+ }
+ },
+ Visibility = "private",
+ RoomVersion = Version
+ };
+
+ if (!string.IsNullOrWhiteSpace(Type))
+ crq.CreationContent.Add("type", Type);
+
+ if (!IsFederatable)
+ crq.CreationContent.Add("m.federate", false);
+
+ var room = await homeserver.CreateRoom(crq);
+
+ await SetBasicRoomInfoAsync(room);
+ await SetStatesAsync(room, ImportantState);
+ await SetAccessAsync(room);
+ await SetStatesAsync(room, InitialState);
+ await SendInvites(room);
+
+ return room;
+ }
+
+ private async Task SendInvites(GenericRoom room) {
+ if (Invites.Count == 0) return;
+
+ var inviteTasks = Invites.Select(async kvp => {
+ try {
+ await room.InviteUserAsync(kvp.Key, kvp.Value);
+ }
+ catch (MatrixException e) {
+ Console.Error.WriteLine("Failed to invite {0} to {1}: {2}", kvp.Key, room.RoomId, e.Message);
+ }
+ });
+
+ await Task.WhenAll(inviteTasks);
+ }
+
+ private async Task SetStatesAsync(GenericRoom room, List<StateEvent> state) {
+ foreach (var ev in state) {
+ await (string.IsNullOrWhiteSpace(ev.StateKey)
+ ? room.SendStateEventAsync(ev.Type, ev.RawContent)
+ : room.SendStateEventAsync(ev.Type, ev.StateKey, ev.RawContent));
+ }
+ }
+
+ private async Task SetBasicRoomInfoAsync(GenericRoom room) {
+ if (!string.IsNullOrWhiteSpace(Name.Name))
+ await room.SendStateEventAsync(RoomNameEventContent.EventId, Name);
+
+ if (!string.IsNullOrWhiteSpace(Topic.Topic))
+ await room.SendStateEventAsync(RoomTopicEventContent.EventId, Topic);
+
+ if (!string.IsNullOrWhiteSpace(Avatar.Url))
+ await room.SendStateEventAsync(RoomAvatarEventContent.EventId, Avatar);
+
+ if (!string.IsNullOrWhiteSpace(AliasLocalPart))
+ CanonicalAlias.Alias = $"#{AliasLocalPart}:{room.Homeserver.ServerName}";
+
+ if (!string.IsNullOrWhiteSpace(CanonicalAlias.Alias)) {
+ await room.Homeserver.SetRoomAliasAsync(CanonicalAlias.Alias!, room.RoomId);
+ await room.SendStateEventAsync(RoomCanonicalAliasEventContent.EventId, CanonicalAlias);
+ }
+ }
+
+ private async Task SetAccessAsync(GenericRoom room) {
+ PowerLevels.Users![room.Homeserver.WhoAmI.UserId] = OwnPowerLevel;
+ await room.SendStateEventAsync(RoomPowerLevelEventContent.EventId, PowerLevels);
+
+ if (!string.IsNullOrWhiteSpace(HistoryVisibility.HistoryVisibility))
+ await room.SendStateEventAsync(RoomHistoryVisibilityEventContent.EventId, HistoryVisibility);
+
+ if (!string.IsNullOrWhiteSpace(JoinRules.JoinRuleValue))
+ await room.SendStateEventAsync(RoomJoinRulesEventContent.EventId, JoinRules);
+ }
+}
+
+public class MatrixConstants {
+ public const long MaxSafeJsonInteger = 9007199254740991L; // 2^53 - 1
+}
\ No newline at end of file
diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs
index 1833bd0..c8e2928 100644
--- a/LibMatrix/Helpers/SyncHelper.cs
+++ b/LibMatrix/Helpers/SyncHelper.cs
@@ -1,22 +1,56 @@
using System.Diagnostics;
using System.Net.Http.Json;
+using System.Reflection;
+using System.Text.Json;
+using ArcaneLibs.Collections;
+using System.Text.Json.Nodes;
using ArcaneLibs.Extensions;
using LibMatrix.Filters;
+using LibMatrix.Helpers.SyncProcessors;
using LibMatrix.Homeservers;
+using LibMatrix.Interfaces.Services;
using LibMatrix.Responses;
using Microsoft.Extensions.Logging;
namespace LibMatrix.Helpers;
-public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null) {
+public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null, IStorageProvider? storageProvider = null) {
+ private readonly Func<SyncResponse?, Task<SyncResponse?>> _msc4222EmulationSyncProcessor = new Msc4222EmulationSyncProcessor(homeserver, logger).EmulateMsc4222;
+
private SyncFilter? _filter;
private string? _namedFilterName;
- private bool _filterIsDirty = false;
- private string? _filterId = null;
+ private bool _filterIsDirty;
+ private string? _filterId;
public string? Since { get; set; }
public int Timeout { get; set; } = 30000;
- public string? SetPresence { get; set; } = "online";
+ public string? SetPresence { get; set; }
+
+ /// <summary>
+ /// Disabling this uses a technically slower code path, useful for checking whether delay comes from waiting for server or deserialising responses
+ /// </summary>
+ public bool UseInternalStreamingSync { get; set; } = true;
+
+ public bool UseMsc4222StateAfter {
+ get;
+ set {
+ field = value;
+ if (value) {
+ AsyncSyncPreprocessors.Add(_msc4222EmulationSyncProcessor);
+ logger?.LogInformation($"Added MSC4222 emulation sync processor");
+ }
+ else {
+ AsyncSyncPreprocessors.Remove(_msc4222EmulationSyncProcessor);
+ logger?.LogInformation($"Removed MSC4222 emulation sync processor");
+ }
+ }
+ } = false;
+
+ public List<Func<SyncResponse?, SyncResponse?>> SyncPreprocessors { get; } = [
+ SimpleSyncProcessors.FillRoomIds
+ ];
+
+ public List<Func<SyncResponse?, Task<SyncResponse?>>> AsyncSyncPreprocessors { get; } = [];
public string? FilterId {
get => _filterId;
@@ -42,16 +76,26 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
_filter = value;
_filterIsDirty = true;
_filterId = null;
+ _namedFilterName = null;
}
}
+ /// <summary>
+ /// Always include all rooms, and their full state according to passed filter
+ /// </summary>
public bool FullState { get; set; }
public bool IsInitialSync { get; set; } = true;
public TimeSpan MinimumDelay { get; set; } = new(0);
- private async Task updateFilterAsync() {
+ public async Task<int> GetUnoptimisedStoreCount() {
+ if (storageProvider is null) return -1;
+ var keys = await storageProvider.GetAllKeysAsync();
+ return keys.Count(static x => !x.StartsWith("old/")) - 1;
+ }
+
+ private async Task UpdateFilterAsync() {
if (!string.IsNullOrWhiteSpace(NamedFilterName)) {
_filterId = await homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(NamedFilterName);
if (_filterId is null)
@@ -61,9 +105,11 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
else if (Filter is not null)
_filterId = (await homeserver.UploadFilterAsync(Filter)).FilterId;
else _filterId = null;
+
+ _filterIsDirty = false;
}
- public async Task<SyncResponse?> SyncAsync(CancellationToken? cancellationToken = null) {
+ public async Task<SyncResponse?> SyncAsync(CancellationToken? cancellationToken = null, bool noDelay = false) {
if (homeserver is null) {
Console.WriteLine("Null passed as homeserver for SyncHelper!");
throw new ArgumentNullException(nameof(homeserver), "Null passed as homeserver for SyncHelper!");
@@ -74,26 +120,86 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
throw new ArgumentNullException(nameof(homeserver.ClientHttpClient), "Null passed as homeserver for SyncHelper!");
}
+ if (storageProvider is null) {
+ var res = await SyncAsyncInternal(cancellationToken, noDelay);
+ if (res is null) return null;
+ if (UseMsc4222StateAfter) res.Msc4222Method = SyncResponse.Msc4222SyncType.Server;
+
+ foreach (var preprocessor in SyncPreprocessors) {
+ res = preprocessor(res);
+ }
+
+ foreach (var preprocessor in AsyncSyncPreprocessors) {
+ res = await preprocessor(res);
+ }
+
+ return res;
+ }
+
+ var key = Since ?? "init";
+ if (await storageProvider.ObjectExistsAsync(key)) {
+ var cached = await storageProvider.LoadObjectAsync<SyncResponse>(key);
+ // We explicitly check that NextBatch doesn't match since to prevent infinite loops...
+ if (cached is not null && cached.NextBatch != Since) {
+ logger?.LogInformation("SyncHelper: Using cached sync response for {}", key);
+ return cached;
+ }
+ }
+
+ var sync = await SyncAsyncInternal(cancellationToken, noDelay);
+ if (sync is null) return null;
+ // Ditto here.
+ if (sync.NextBatch != Since) await storageProvider.SaveObjectAsync(key, sync);
+
+ if (UseMsc4222StateAfter) sync.Msc4222Method = SyncResponse.Msc4222SyncType.Server;
+
+ foreach (var preprocessor in SyncPreprocessors) {
+ sync = preprocessor(sync);
+ }
+
+ foreach (var preprocessor in AsyncSyncPreprocessors) {
+ sync = await preprocessor(sync);
+ }
+
+ return sync;
+ }
+
+ private async Task<SyncResponse?> SyncAsyncInternal(CancellationToken? cancellationToken = null, bool noDelay = false) {
var sw = Stopwatch.StartNew();
- if (_filterIsDirty) await updateFilterAsync();
+ if (_filterIsDirty) await UpdateFilterAsync();
- var url = $"/_matrix/client/v3/sync?timeout={Timeout}&set_presence={SetPresence}&full_state={(FullState ? "true" : "false")}";
+ var url = $"/_matrix/client/v3/sync?timeout={Timeout}";
+ if (!string.IsNullOrWhiteSpace(SetPresence)) url += $"&set_presence={SetPresence}";
if (!string.IsNullOrWhiteSpace(Since)) url += $"&since={Since}";
if (_filterId is not null) url += $"&filter={_filterId}";
+ if (FullState) url += "&full_state=true";
+ if (UseMsc4222StateAfter) url += "&org.matrix.msc4222.use_state_after=true&use_state_after=true"; // We use both unstable and stable names for compatibility
- logger?.LogInformation("SyncHelper: Calling: {}", url);
+ // logger?.LogInformation("SyncHelper: Calling: {}", url);
try {
- var httpResp = await homeserver.ClientHttpClient.GetAsync(url, cancellationToken ?? CancellationToken.None);
- if (httpResp is null) throw new NullReferenceException("Failed to send HTTP request");
- logger?.LogInformation("Got sync response: {} bytes, {} elapsed", httpResp.Content.Headers.ContentLength ?? -1, sw.Elapsed);
- var deserializeSw = Stopwatch.StartNew();
- var resp = await httpResp.Content.ReadFromJsonAsync<SyncResponse>(cancellationToken: cancellationToken ?? CancellationToken.None,
- jsonTypeInfo: SyncResponseSerializerContext.Default.SyncResponse);
- logger?.LogInformation("Deserialized sync response: {} bytes, {} elapsed, {} total", httpResp.Content.Headers.ContentLength ?? -1, deserializeSw.Elapsed, sw.Elapsed);
+ SyncResponse? resp;
+ if (UseInternalStreamingSync) {
+ resp = await homeserver.ClientHttpClient.GetFromJsonAsync<SyncResponse>(url, cancellationToken: cancellationToken ?? CancellationToken.None);
+ logger?.LogInformation("Got sync response: ~{} bytes, {} elapsed", resp.ToJson(false, true, true).Length, sw.Elapsed);
+ }
+ else {
+ var httpResp = await homeserver.ClientHttpClient.GetAsync(url, cancellationToken ?? CancellationToken.None);
+ if (httpResp is null) throw new NullReferenceException("Failed to send HTTP request");
+ var receivedTime = sw.Elapsed;
+ var deserializeSw = Stopwatch.StartNew();
+ resp = await httpResp.Content.ReadFromJsonAsync(cancellationToken: cancellationToken ?? CancellationToken.None,
+ jsonTypeInfo: SyncResponseSerializerContext.Default.SyncResponse);
+ logger?.LogInformation("Deserialized sync response: {} bytes, {} response time, {} deserialize time, {} total", httpResp.GetContentLength(), receivedTime,
+ deserializeSw.Elapsed, sw.Elapsed);
+ }
+
var timeToWait = MinimumDelay.Subtract(sw.Elapsed);
- if (timeToWait.TotalMilliseconds > 0)
+ if (!noDelay && timeToWait.TotalMilliseconds > 0) {
+ logger?.LogWarning("SyncAsyncInternal: Waiting {delay}", timeToWait);
await Task.Delay(timeToWait);
+ }
+
return resp;
}
catch (TaskCanceledException) {
@@ -103,6 +209,8 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
catch (Exception e) {
Console.WriteLine(e);
logger?.LogError(e, "Failed to sync!\n{}", e.ToString());
+ await Task.WhenAll(ExceptionHandlers.Select(x => x.Invoke(e)).ToList());
+ if (e is MatrixException { ErrorCode: MatrixException.ErrorCodes.M_UNKNOWN_TOKEN }) throw;
}
return null;
@@ -110,10 +218,17 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
public async IAsyncEnumerable<SyncResponse> EnumerateSyncAsync(CancellationToken? cancellationToken = null) {
while (!cancellationToken?.IsCancellationRequested ?? true) {
- var sync = await SyncAsync(cancellationToken);
+ var sw = Stopwatch.StartNew();
+ var sync = await SyncAsync(cancellationToken, noDelay: true);
if (sync is null) continue;
if (!string.IsNullOrWhiteSpace(sync.NextBatch)) Since = sync.NextBatch;
yield return sync;
+
+ var timeToWait = MinimumDelay.Subtract(sw.Elapsed);
+ if (timeToWait.TotalMilliseconds > 0) {
+ logger?.LogWarning("EnumerateSyncAsync: Waiting {delay}", timeToWait);
+ await Task.Delay(timeToWait);
+ }
}
}
@@ -183,7 +298,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
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) {
+ foreach (var stateEventResponse in updatedRoom.Value.Timeline.Events ?? []) {
stateEventResponse.RoomId = updatedRoom.Key;
var tasks = TimelineEventHandlers.Select(x => x(stateEventResponse)).ToList();
await Task.WhenAll(tasks);
@@ -210,7 +325,12 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
/// Event fired when an account data event is received
/// </summary>
public List<Func<StateEventResponse, Task>> AccountDataReceivedHandlers { get; } = new();
-
+
+ /// <summary>
+ /// Event fired when an exception is thrown
+ /// </summary>
+ public List<Func<Exception, Task>> ExceptionHandlers { get; } = new();
+
private void Log(string message) {
if (logger is null) Console.WriteLine(message);
else logger.LogInformation(message);
diff --git a/LibMatrix/Helpers/SyncProcessors/Msc4222EmulationSyncProcessor.cs b/LibMatrix/Helpers/SyncProcessors/Msc4222EmulationSyncProcessor.cs
new file mode 100644
index 0000000..e34b5cf
--- /dev/null
+++ b/LibMatrix/Helpers/SyncProcessors/Msc4222EmulationSyncProcessor.cs
@@ -0,0 +1,210 @@
+using System.Diagnostics;
+using System.Timers;
+using ArcaneLibs.Extensions;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using Microsoft.Extensions.Logging;
+
+namespace LibMatrix.Helpers.SyncProcessors;
+
+public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homeserver, ILogger? logger) {
+ private static bool StateEventsMatch(StateEventResponse a, StateEventResponse b) {
+ return a.Type == b.Type && a.StateKey == b.StateKey;
+ }
+
+ private static bool StateEventIsNewer(StateEventResponse a, StateEventResponse b) {
+ return StateEventsMatch(a, b) && a.OriginServerTs < b.OriginServerTs;
+ }
+
+ public async Task<SyncResponse?> EmulateMsc4222(SyncResponse? resp) {
+ var sw = Stopwatch.StartNew();
+ if (resp is null or { Rooms: null }) return resp;
+
+ if (
+ resp.Rooms.Join?.Any(x => x.Value.StateAfter is { Events.Count: > 0 }) == true
+ || resp.Rooms.Leave?.Any(x => x.Value.StateAfter is { Events.Count: > 0 }) == true
+ ) {
+ logger?.Log(sw.ElapsedMilliseconds > 100 ? LogLevel.Warning : LogLevel.Debug,
+ "Msc4222EmulationSyncProcessor.EmulateMsc4222 determined that no emulation is needed in {elapsed}", sw.Elapsed);
+ return resp;
+ }
+
+ resp = await EmulateMsc4222Internal(resp, sw);
+
+ return SimpleSyncProcessors.FillRoomIds(resp);
+ }
+
+ private async Task<SyncResponse?> EmulateMsc4222Internal(SyncResponse? resp, Stopwatch sw) {
+ var modified = false;
+ List<Task<bool>> tasks = [];
+ if (resp.Rooms is { Join.Count: > 0 }) {
+ tasks.AddRange(resp.Rooms.Join.Select(ProcessJoinedRooms).ToList());
+ }
+
+ if (resp.Rooms is { Leave.Count: > 0 }) {
+ tasks.AddRange(resp.Rooms.Leave.Select(ProcessLeftRooms).ToList());
+ }
+
+ var tasksEnum = tasks.ToAsyncEnumerable();
+ await foreach (var wasModified in tasksEnum) {
+ if (wasModified) {
+ modified = true;
+ }
+ }
+
+ logger?.Log(sw.ElapsedMilliseconds > 100 ? LogLevel.Warning : LogLevel.Debug,
+ "Msc4222EmulationSyncProcessor.EmulateMsc4222 processed {joinCount}/{leaveCount} rooms in {elapsed} (modified: {modified})",
+ resp.Rooms?.Join?.Count ?? 0, resp.Rooms?.Leave?.Count ?? 0, sw.Elapsed, modified);
+
+ if (modified)
+ resp.Msc4222Method = SyncResponse.Msc4222SyncType.Emulated;
+
+ return resp;
+ }
+
+ private async Task<bool> ProcessJoinedRooms(KeyValuePair<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure> roomData) {
+ var (roomId, data) = roomData;
+ var room = homeserver.GetRoom(roomId);
+
+ if (data.StateAfter is { Events.Count: > 0 }) {
+ return false;
+ }
+
+ data.StateAfter = new() { Events = [] };
+
+ data.StateAfter = new() {
+ Events = []
+ };
+
+ var oldState = new List<StateEventResponse>();
+ if (data.State is { Events.Count: > 0 }) {
+ oldState.ReplaceBy(data.State.Events, StateEventIsNewer);
+ }
+
+ if (data.Timeline is { Limited: true }) {
+ if (data.Timeline.Events != null)
+ oldState.ReplaceBy(data.Timeline.Events, StateEventIsNewer);
+
+ try {
+ var timeline = await homeserver.GetRoom(roomId).GetMessagesAsync(limit: 250);
+ if (timeline is { State.Count: > 0 }) {
+ oldState.ReplaceBy(timeline.State, StateEventIsNewer);
+ }
+
+ if (timeline is { Chunk.Count: > 0 }) {
+ oldState.ReplaceBy(timeline.Chunk.Where(x => x.StateKey != null), StateEventIsNewer);
+ }
+ }
+ catch (Exception e) {
+ logger?.LogWarning("Msc4222Emulation: Failed to get timeline for room {roomId}, state may be incomplete!\n{exception}", roomId, e);
+ }
+ }
+
+ oldState = oldState.DistinctBy(x => (x.Type, x.StateKey)).ToList();
+
+ // Different order: we need oldState here to reduce the set
+ try {
+ data.StateAfter.Events = (await room.GetFullStateAsListAsync())
+ // .Where(x=> oldState.Any(y => StateEventsMatch(x, y)))
+ // .Join(oldState, x => (x.Type, x.StateKey), y => (y.Type, y.StateKey), (x, y) => x)
+ .IntersectBy(oldState.Select(s => (s.Type, s.StateKey)), s => (s.Type, s.StateKey))
+ .ToList();
+
+ data.State = null;
+ return true;
+ }
+ catch (Exception e) {
+ logger?.LogWarning("Msc4222Emulation: Failed to get full state for room {roomId}, state may be incomplete!\n{exception}", roomId, e);
+ }
+
+ var tasks = oldState
+ .Select(async oldEvt => {
+ try {
+ return await room.GetStateEventAsync(oldEvt.Type, oldEvt.StateKey!);
+ }
+ catch (Exception e) {
+ logger?.LogWarning("Msc4222Emulation: Failed to get state event {type}/{stateKey} for room {roomId}, state may be incomplete!\n{exception}",
+ oldEvt.Type, oldEvt.StateKey, roomId, e);
+ return oldEvt;
+ }
+ });
+
+ var tasksEnum = tasks.ToAsyncEnumerable();
+ await foreach (var evt in tasksEnum) {
+ data.StateAfter.Events.Add(evt);
+ }
+
+ data.State = null;
+
+ return true;
+ }
+
+ private async Task<bool> ProcessLeftRooms(KeyValuePair<string, SyncResponse.RoomsDataStructure.LeftRoomDataStructure> roomData) {
+ var (roomId, data) = roomData;
+ var room = homeserver.GetRoom(roomId);
+
+ if (data.StateAfter is { Events.Count: > 0 }) {
+ return false;
+ }
+
+ data.StateAfter = new() {
+ Events = []
+ };
+
+ try {
+ data.StateAfter.Events = await room.GetFullStateAsListAsync();
+ data.State = null;
+ return true;
+ }
+ catch (Exception e) {
+ logger?.LogWarning("Msc4222Emulation: Failed to get full state for room {roomId}, state may be incomplete!\n{exception}", roomId, e);
+ }
+
+ var oldState = new List<StateEventResponse>();
+ if (data.State is { Events.Count: > 0 }) {
+ oldState.ReplaceBy(data.State.Events, StateEventIsNewer);
+ }
+
+ if (data.Timeline is { Limited: true }) {
+ if (data.Timeline.Events != null)
+ oldState.ReplaceBy(data.Timeline.Events, StateEventIsNewer);
+
+ try {
+ var timeline = await homeserver.GetRoom(roomId).GetMessagesAsync(limit: 250);
+ if (timeline is { State.Count: > 0 }) {
+ oldState.ReplaceBy(timeline.State, StateEventIsNewer);
+ }
+
+ if (timeline is { Chunk.Count: > 0 }) {
+ oldState.ReplaceBy(timeline.Chunk.Where(x => x.StateKey != null), StateEventIsNewer);
+ }
+ }
+ catch (Exception e) {
+ logger?.LogWarning("Msc4222Emulation: Failed to get timeline for room {roomId}, state may be incomplete!\n{exception}", roomId, e);
+ }
+ }
+
+ oldState = oldState.DistinctBy(x => (x.Type, x.StateKey)).ToList();
+
+ var tasks = oldState
+ .Select(async oldEvt => {
+ try {
+ return await room.GetStateEventAsync(oldEvt.Type, oldEvt.StateKey!);
+ }
+ catch (Exception e) {
+ logger?.LogWarning("Msc4222Emulation: Failed to get state event {type}/{stateKey} for room {roomId}, state may be incomplete!\n{exception}",
+ oldEvt.Type, oldEvt.StateKey, roomId, e);
+ return oldEvt;
+ }
+ });
+
+ var tasksEnum = tasks.ToAsyncEnumerable();
+ await foreach (var evt in tasksEnum) {
+ data.StateAfter.Events.Add(evt);
+ }
+
+ data.State = null;
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Helpers/SyncProcessors/SimpleSyncProcessors.cs b/LibMatrix/Helpers/SyncProcessors/SimpleSyncProcessors.cs
new file mode 100644
index 0000000..5981cb5
--- /dev/null
+++ b/LibMatrix/Helpers/SyncProcessors/SimpleSyncProcessors.cs
@@ -0,0 +1,47 @@
+using System.Diagnostics;
+using LibMatrix.Responses;
+
+namespace LibMatrix.Helpers.SyncProcessors;
+
+public class SimpleSyncProcessors {
+ public static SyncResponse? FillRoomIds(SyncResponse? resp) {
+ var sw = Stopwatch.StartNew();
+ if (resp is not { Rooms: not null }) return resp;
+ if (resp.Rooms.Join is { Count: > 0 })
+ Parallel.ForEach(resp.Rooms.Join, (roomEntry) => {
+ var (id, data) = roomEntry;
+ if (data.AccountData is { Events.Count: > 0 })
+ Parallel.ForEach(data.AccountData.Events, evt => evt.RoomId = id);
+ if (data.Ephemeral is { Events.Count: > 0 })
+ Parallel.ForEach(data.Ephemeral.Events, evt => evt.RoomId = id);
+ if (data.Timeline is { Events.Count: > 0 })
+ Parallel.ForEach(data.Timeline.Events, evt => evt.RoomId = id);
+ if (data.State is { Events.Count: > 0 })
+ Parallel.ForEach(data.State.Events, evt => evt.RoomId = id);
+ if (data.StateAfter is { Events.Count: > 0 })
+ Parallel.ForEach(data.StateAfter.Events, evt => evt.RoomId = id);
+ });
+ if (resp.Rooms.Leave is { Count: > 0 })
+ Parallel.ForEach(resp.Rooms.Leave, (roomEntry) => {
+ var (id, data) = roomEntry;
+ if (data.AccountData is { Events.Count: > 0 })
+ Parallel.ForEach(data.AccountData.Events, evt => evt.RoomId = id);
+ if (data.Timeline is { Events.Count: > 0 })
+ Parallel.ForEach(data.Timeline.Events, evt => evt.RoomId = id);
+ if (data.State is { Events.Count: > 0 })
+ Parallel.ForEach(data.State.Events, evt => evt.RoomId = id);
+ if (data.StateAfter is { Events.Count: > 0 })
+ Parallel.ForEach(data.StateAfter.Events, evt => evt.RoomId = id);
+ });
+ if (resp.Rooms.Invite is { Count: > 0 })
+ Parallel.ForEach(resp.Rooms.Invite, (roomEntry) => {
+ var (id, data) = roomEntry;
+ if (data.InviteState is { Events.Count: > 0 })
+ Parallel.ForEach(data.InviteState.Events, evt => evt.RoomId = id);
+ });
+
+ Console.WriteLine($"SimpleSyncProcessors.FillRoomIds took {sw.Elapsed}");
+
+ return resp;
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Helpers/SyncStateResolver.cs b/LibMatrix/Helpers/SyncStateResolver.cs
index 72d600d..f111c79 100644
--- a/LibMatrix/Helpers/SyncStateResolver.cs
+++ b/LibMatrix/Helpers/SyncStateResolver.cs
@@ -1,12 +1,20 @@
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Text.Json;
+using System.Threading.Tasks.Dataflow;
+using ArcaneLibs.Extensions;
using LibMatrix.Extensions;
using LibMatrix.Filters;
using LibMatrix.Homeservers;
+using LibMatrix.Interfaces.Services;
using LibMatrix.Responses;
using Microsoft.Extensions.Logging;
namespace LibMatrix.Helpers;
-public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null) {
+public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null, IStorageProvider? storageProvider = null) {
public string? Since { get; set; }
public int Timeout { get; set; } = 30000;
public string? SetPresence { get; set; } = "online";
@@ -15,7 +23,20 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
public SyncResponse? MergedState { get; set; }
- private SyncHelper _syncHelper = new(homeserver, logger);
+ private SyncHelper _syncHelper = new(homeserver, logger, storageProvider);
+
+ private async Task<SyncResponse?> LoadSyncResponse(string key) {
+ if (storageProvider is null) ArgumentNullException.ThrowIfNull(storageProvider);
+ var stream = await storageProvider.LoadStreamAsync(key);
+ return JsonSerializer.Deserialize<SyncResponse>(stream!, SyncResponseSerializerContext.Default.SyncResponse);
+ }
+
+ private async Task SaveSyncResponse(string key, SyncResponse value) {
+ ArgumentNullException.ThrowIfNull(storageProvider);
+ var ms = new MemoryStream();
+ await JsonSerializer.SerializeAsync(ms, value, SyncResponseSerializerContext.Default.SyncResponse);
+ await storageProvider.SaveStreamAsync(key, ms);
+ }
public async Task<(SyncResponse next, SyncResponse merged)> ContinueAsync(CancellationToken? cancellationToken = null) {
// copy properties
@@ -24,149 +45,615 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
_syncHelper.SetPresence = SetPresence;
_syncHelper.Filter = Filter;
_syncHelper.FullState = FullState;
- // run sync
- var sync = await _syncHelper.SyncAsync(cancellationToken);
+
+ var sync = await _syncHelper.SyncAsync(cancellationToken, noDelay: false);
if (sync is null) return await ContinueAsync(cancellationToken);
+
if (MergedState is null) MergedState = sync;
- else MergedState = MergeSyncs(MergedState, sync);
+ else MergedState = await MergeSyncs(MergedState, sync);
Since = sync.NextBatch;
+
return (sync, MergedState);
}
- private SyncResponse MergeSyncs(SyncResponse oldState, SyncResponse newState) {
- oldState.NextBatch = newState.NextBatch ?? oldState.NextBatch;
+ // private async IAsyncEnumerable<List<SyncResponse>> MergeP() {
+
+ // }
+
+ private async Task<SyncResponse?> OptimiseFrom(string start, Action<int, int>? progressCallback = null) {
+ var a = GetSerializedUnoptimisedResponses(start);
+ SyncResponse merged = null!;
+ int iters = 0;
+ var sw = Stopwatch.StartNew();
+ await foreach (var (key, resp) in a) {
+ if (resp is null) continue;
+ iters++;
+ // if (key == "init") _merged = resp;
+ // else _merged = await MergeSyncs(_merged, resp);
+ // Console.WriteLine($"{key} @ {resp.GetDerivedSyncTime()} -> {resp.NextBatch}");
+ }
- oldState.AccountData ??= new EventList();
- oldState.AccountData.Events ??= new List<StateEventResponse>();
- if (newState.AccountData?.Events is not null)
- oldState.AccountData.Events.MergeStateEventLists(newState.AccountData?.Events ?? new List<StateEventResponse>());
+ Console.WriteLine($"OptimiseFrom {start} finished in {sw.Elapsed.TotalMilliseconds}ms with {iters} iterations");
- oldState.Presence ??= new SyncResponse.PresenceDataStructure();
- if (newState.Presence?.Events is not null)
- oldState.Presence.Events.MergeStateEventLists(newState.Presence?.Events ?? new List<StateEventResponse>());
+ return merged;
+ }
- oldState.DeviceOneTimeKeysCount ??= new Dictionary<string, int>();
- if (newState.DeviceOneTimeKeysCount is not null)
- foreach (var (key, value) in newState.DeviceOneTimeKeysCount)
- oldState.DeviceOneTimeKeysCount[key] = value;
+ private async Task<List<string>> GetSerializedUnoptimisedKeysParallel(string start = "init") {
+ Dictionary<string, string> pairs = [];
+ var unoptimisedKeys = (await storageProvider.GetAllKeysAsync()).Where(static x => !x.Contains('/')).ToFrozenSet();
+ await Parallel.ForEachAsync(unoptimisedKeys, async (key, _) => {
+ var data = await storageProvider.LoadObjectAsync<SyncResponse>(key, SyncResponseSerializerContext.Default.SyncResponse);
+ if (data is null) return;
+ lock (pairs)
+ pairs.Add(key, data.NextBatch);
+ });
+
+ var serializedKeys = new List<string>();
+ var currentKey = start;
+ while (pairs.TryGetValue(currentKey, out var nextKey)) {
+ serializedKeys.Add(currentKey);
+ currentKey = nextKey;
+ }
- oldState.Rooms ??= new SyncResponse.RoomsDataStructure();
- if (newState.Rooms is not null)
- oldState.Rooms = MergeRoomsDataStructure(oldState.Rooms, newState.Rooms);
+ return serializedKeys;
+ }
- oldState.ToDevice ??= new EventList();
- oldState.ToDevice.Events ??= new List<StateEventResponse>();
- if (newState.ToDevice?.Events is not null)
- oldState.ToDevice.Events.MergeStateEventLists(newState.ToDevice?.Events ?? new List<StateEventResponse>());
+ private async Task<SyncResponse> MergeRecursive(string[] keys, int depth = 0) {
+ if (keys.Length > 10) {
+ var newKeys = keys.Chunk((keys.Length / 2) + 1).ToArray();
+ var (left, right) = (MergeRecursive(newKeys[0], depth + 1), MergeRecursive(newKeys[1], depth + 1));
+ await Task.WhenAll(left, right);
+ return await MergeSyncs(await left, await right);
+ }
- oldState.DeviceLists ??= new SyncResponse.DeviceListsDataStructure();
- 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);
+ // Console.WriteLine("Hit max depth: " + depth);
+ SyncResponse merged = await LoadSyncResponse(keys[0]);
+ foreach (var key in keys[1..]) {
+ merged = await MergeSyncs(merged, await LoadSyncResponse(key));
+ }
- return oldState;
+ return merged;
}
-#region Merge rooms
+ public async Task OptimiseStore(Action<int, int>? progressCallback = null) {
+ if (storageProvider is null) return;
+ if (!await storageProvider.ObjectExistsAsync("init")) return;
+ //
+ // {
+ // var a = GetSerializedUnoptimisedResponses();
+ // SyncResponse _merged = null!;
+ // await foreach (var (key, resp) in a) {
+ // if (resp is null) continue;
+ // // if (key == "init") _merged = resp;
+ // // else _merged = await MergeSyncs(_merged, resp);
+ // // Console.WriteLine($"{key} @ {resp.GetDerivedSyncTime()} -> {resp.NextBatch}");
+ // }
+ // Environment.Exit(0);
+ // }
+
+ {
+ // List<string> serialisedKeys = new(4000000);
+ // await foreach (var res in GetSerializedUnoptimisedResponses()) {
+ // if (res.resp is null) continue;
+ // serialisedKeys.Add(res.key);
+ // if (serialisedKeys.Count % 1000 == 0) _ = Console.Out.WriteAsync($"{serialisedKeys.Count}\r");
+ // }
+
+ List<string> serialisedKeys = await GetSerializedUnoptimisedKeysParallel();
+
+ await MergeRecursive(serialisedKeys.ToArray());
+
+ // var chunkSize = serialisedKeys.Count / Environment.ProcessorCount;
+ // var chunks = serialisedKeys.Chunk(chunkSize+1).Select(x => (x.First(), x.Length)).ToList();
+ // Console.WriteLine($"Got {chunks.Count} chunks:");
+ // foreach (var chunk in chunks) {
+ // Console.WriteLine($"Chunk {chunk.Item1} with length {chunk.Length}");
+ // }
+ //
+ // var mergeTasks = chunks.Select(async chunk => {
+ // var (startKey, length) = chunk;
+ // string currentKey = startKey;
+ // SyncResponse merged = await storageProvider.LoadObjectAsync<SyncResponse>(currentKey, SyncResponseSerializerContext.Default.SyncResponse);
+ // for (int i = 0; i < length; i++) {
+ // if (i % 1000 == 0) Console.Write($"{i}... \r");
+ // var newData = await storageProvider.LoadObjectAsync<SyncResponse>(currentKey, SyncResponseSerializerContext.Default.SyncResponse);
+ // merged = await MergeSyncs(merged, newData);
+ // currentKey = merged.NextBatch;
+ // }
+ //
+ // return merged;
+ // }).ToList();
+ //
+ // var mergedResults = await Task.WhenAll(mergeTasks);
+ // SyncResponse _merged = mergedResults[0];
+ // foreach (var key in mergedResults[1..]) {
+ // _merged = await MergeSyncs(_merged, key);
+ // }
+ }
+
+ Environment.Exit(0);
+
+ return;
+
+ var totalSw = Stopwatch.StartNew();
+ Console.Write("Optimising sync store...");
+ var initLoadTask = LoadSyncResponse("init");
+ var keys = (await storageProvider.GetAllKeysAsync()).Where(static x => !x.StartsWith("old/")).ToFrozenSet();
+ var count = keys.Count - 1;
+ int total = count;
+ Console.WriteLine($"Found {count} entries to optimise in {totalSw.Elapsed}.");
+
+ var merged = await initLoadTask;
+ if (merged is null) return;
+ if (!keys.Contains(merged.NextBatch)) {
+ Console.WriteLine("Next response after initial sync is not present, not checkpointing!");
+ return;
+ }
+
+ // if (keys.Count > 100_000) {
+ // // batch data by core count
+ //
+ // return;
+ // }
+
+ // We back up old entries
+ var oldPath = $"old/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
+ await storageProvider.MoveObjectAsync("init", $"{oldPath}/init");
+
+ var moveTasks = new List<Task>();
+
+ Dictionary<string, Dictionary<string, TimeSpan>> traces = [];
+ string[] loopTrace = new string[4];
+ while (keys.Contains(merged.NextBatch)) {
+ loopTrace[0] = $"Merging {merged.NextBatch}, {--count} remaining";
+ var sw = Stopwatch.StartNew();
+ var swt = Stopwatch.StartNew();
+ var next = await LoadSyncResponse(merged.NextBatch);
+ loopTrace[1] = $"Load {sw.GetElapsedAndRestart().TotalMilliseconds}ms";
+ if (next is null || merged.NextBatch == next.NextBatch) break;
+
+ // back up old entry
+ moveTasks.Add(storageProvider.MoveObjectAsync(merged.NextBatch, $"{oldPath}/{merged.NextBatch}"));
+
+ if (moveTasks.Count >= 250)
+ moveTasks.RemoveAll(t => t.IsCompleted);
+
+ if (moveTasks.Count >= 500) {
+ Console.Write("Reached 500 moveTasks... ");
+ moveTasks.RemoveAll(t => t.IsCompleted);
+ Console.WriteLine($"{moveTasks.Count} remaining");
+ }
+
+ var trace = new Dictionary<string, TimeSpan>();
+ traces[merged.NextBatch] = trace;
+ merged = await MergeSyncs(merged, next, trace);
+ loopTrace[2] = $"Merge {sw.GetElapsedAndRestart().TotalMilliseconds}ms";
+ loopTrace[3] = $"Total {swt.Elapsed.TotalMilliseconds}ms";
+
+ if (swt.ElapsedMilliseconds >= 25)
+ Console.WriteLine(string.Join("... ", loopTrace));
+
+ if (count % 50 == 0)
+ progressCallback?.Invoke(count, total);
+#if WRITE_TRACE
+ var traceString = string.Join("\n", traces.Select(x => $"{x.Key}\t{x.Value.ToJson(indent: false, ignoreNull: true)}"));
+ var ms = new MemoryStream(Encoding.UTF8.GetBytes(traceString));
+ var traceSaveTask = storageProvider.SaveStreamAsync($"traces/{oldPath}", ms);
+ var slowtraceString = string.Join("\n",
+ traces
+ .Where(x=>x.Value.Max(y=>y.Value.TotalMilliseconds) >= 100)
+ .OrderBy(x=>x.Value.Max(y=>y.Value))
+ .Select(x => $"{x.Key}\t{x.Value.Where(y => y.Value.TotalMilliseconds >= 100).ToDictionary().ToJson(indent: false, ignoreNull: true)}"));
+ var slowms = new MemoryStream(Encoding.UTF8.GetBytes(slowtraceString));
+ var slowTraceSaveTask = storageProvider.SaveStreamAsync($"traces/{oldPath}-slow", slowms);
+ var slow1straceString = string.Join("\n",
+ traces
+ .Where(x=>x.Value.Max(y=>y.Value.TotalMilliseconds) >= 1000)
+ .OrderBy(x=>x.Value.Max(y=>y.Value))
+ .Select(x => $"{x.Key}\t{x.Value.Where(y => y.Value.TotalMilliseconds >= 1000).ToDictionary().ToJson(indent: false, ignoreNull: true)}"));
+ var slow1sms = new MemoryStream(Encoding.UTF8.GetBytes(slow1straceString));
+ var slow1sTraceSaveTask = storageProvider.SaveStreamAsync($"traces/{oldPath}-slow-1s", slow1sms);
+
+ await Task.WhenAll(traceSaveTask, slowTraceSaveTask, slow1sTraceSaveTask);
+#endif
+ }
- private SyncResponse.RoomsDataStructure MergeRoomsDataStructure(SyncResponse.RoomsDataStructure oldState, SyncResponse.RoomsDataStructure newState) {
- oldState.Join ??= new Dictionary<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>();
- foreach (var (key, value) in newState.Join ?? new Dictionary<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>())
- if (!oldState.Join.ContainsKey(key)) oldState.Join[key] = value;
- else oldState.Join[key] = MergeJoinedRoomDataStructure(oldState.Join[key], value);
+ await SaveSyncResponse("init", merged);
+ await Task.WhenAll(moveTasks);
- oldState.Invite ??= new Dictionary<string, SyncResponse.RoomsDataStructure.InvitedRoomDataStructure>();
- foreach (var (key, value) in newState.Invite ?? new Dictionary<string, SyncResponse.RoomsDataStructure.InvitedRoomDataStructure>())
- if (!oldState.Invite.ContainsKey(key)) oldState.Invite[key] = value;
- else oldState.Invite[key] = MergeInvitedRoomDataStructure(oldState.Invite[key], value);
+ Console.WriteLine($"Optimised store in {totalSw.Elapsed.TotalMilliseconds}ms");
+ }
+
+ /// <summary>
+ /// Remove all but initial sync and last checkpoint
+ /// </summary>
+ public async Task RemoveOldSnapshots() {
+ if (storageProvider is null) return;
+ var sw = Stopwatch.StartNew();
+
+ var map = await GetCheckpointMap();
+ if (map is null) return;
+ if (map.Count < 3) return;
+
+ var toRemove = map.Keys.Skip(1).Take(map.Count - 2).ToList();
+ Console.Write("Cleaning up old snapshots: ");
+ foreach (var key in toRemove) {
+ var path = $"old/{key}/init";
+ if (await storageProvider.ObjectExistsAsync(path)) {
+ Console.Write($"{key}... ");
+ await storageProvider.DeleteObjectAsync(path);
+ }
+ }
+
+ Console.WriteLine("Done!");
+ Console.WriteLine($"Removed {toRemove.Count} old snapshots in {sw.Elapsed.TotalMilliseconds}ms");
+ }
+
+ public async Task UnrollOptimisedStore() {
+ if (storageProvider is null) return;
+ Console.WriteLine("WARNING: Unrolling sync store!");
+ }
+
+ public async Task SquashOptimisedStore(int targetCountPerCheckpoint) {
+ Console.Write($"Balancing optimised store to {targetCountPerCheckpoint} per checkpoint...");
+ var checkpoints = await GetCheckpointMap();
+ if (checkpoints is null) return;
+
+ Console.WriteLine(
+ $" Stats: {checkpoints.Count} checkpoints with [{checkpoints.Min(x => x.Value.Count)} < ~{checkpoints.Average(x => x.Value.Count)} < {checkpoints.Max(x => x.Value.Count)}] entries");
+ Console.WriteLine($"Found {checkpoints?.Count ?? 0} checkpoints.");
+ }
+
+ public async Task dev() {
+ int i = 0;
+ var sw = Stopwatch.StartNew();
+ var hist = GetSerialisedHistory();
+ await foreach (var (key, resp) in hist) {
+ if (resp is null) continue;
+ // Console.WriteLine($"[{++i}] {key} -> {resp.NextBatch} ({resp.GetDerivedSyncTime()})");
+ i++;
+ }
+
+ Console.WriteLine($"Iterated {i} syncResponses in {sw.Elapsed}");
+ Environment.Exit(0);
+ }
+
+ private async IAsyncEnumerable<(string key, SyncResponse? resp)> GetSerialisedHistory() {
+ if (storageProvider is null) yield break;
+ var map = await GetCheckpointMap();
+ var currentRange = map.First();
+ var nextKey = $"old/{map.First().Key}/init";
+ var next = storageProvider.LoadObjectAsync<SyncResponse>(nextKey);
+ while (true) {
+ var data = await next;
+ if (data is null) break;
+ yield return (nextKey, data);
+ if (currentRange.Value.Contains(data.NextBatch)) {
+ nextKey = $"old/{currentRange.Key}/{data.NextBatch}";
+ }
+ else if (map.Any(x => x.Value.Contains(data.NextBatch))) {
+ currentRange = map.First(x => x.Value.Contains(data.NextBatch));
+ nextKey = $"old/{currentRange.Key}/{data.NextBatch}";
+ }
+ else if (await storageProvider.ObjectExistsAsync(data.NextBatch)) {
+ nextKey = data.NextBatch;
+ }
+ else break;
+
+ next = storageProvider.LoadObjectAsync<SyncResponse>(nextKey);
+ }
+ }
+
+ private async IAsyncEnumerable<(string key, SyncResponse? resp)> GetSerializedUnoptimisedResponses(string since = "init") {
+ if (storageProvider is null) yield break;
+ var nextKey = since;
+ var next = storageProvider.LoadObjectAsync<SyncResponse>(nextKey);
+ while (true) {
+ var data = await next;
+
+ if (data is null) break;
+ yield return (nextKey, data);
+ if (await storageProvider.ObjectExistsAsync(data.NextBatch)) {
+ nextKey = data.NextBatch;
+ }
+ else break;
+
+ next = storageProvider.LoadObjectAsync<SyncResponse>(nextKey);
+ }
+ }
+
+ public async Task<SyncResponse?> GetMergedUpTo(DateTime time) {
+ if (storageProvider is null) return null;
+ var unixTime = new DateTimeOffset(time.ToUniversalTime()).ToUnixTimeMilliseconds();
+ var map = await GetCheckpointMap();
+ if (map is null) return new();
+ var stream = GetSerialisedHistory().GetAsyncEnumerator();
+ SyncResponse? merged = await stream.MoveNextAsync() ? stream.Current.resp : null;
+
+ if (merged.GetDerivedSyncTime() > unixTime) {
+ Console.WriteLine("Initial sync is already past the target time!");
+ Console.WriteLine($"CURRENT: {merged.GetDerivedSyncTime()} (UTC: {DateTimeOffset.FromUnixTimeMilliseconds(merged.GetDerivedSyncTime())})");
+ Console.WriteLine($" TARGET: {unixTime} ({time.Kind}: {time}, UTC: {time.ToUniversalTime()})");
+ return null;
+ }
- oldState.Leave ??= new Dictionary<string, SyncResponse.RoomsDataStructure.LeftRoomDataStructure>();
- foreach (var (key, value) in newState.Leave ?? new Dictionary<string, SyncResponse.RoomsDataStructure.LeftRoomDataStructure>()) {
- 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);
+ while (await stream.MoveNextAsync()) {
+ var (key, resp) = stream.Current;
+ if (resp is null) continue;
+ if (resp.GetDerivedSyncTime() > unixTime) break;
+ merged = await MergeSyncs(merged, resp);
}
+ return merged;
+ }
+
+ private async Task<ImmutableSortedDictionary<ulong, FrozenSet<string>>> GetCheckpointMap() {
+ if (storageProvider is null) return null;
+ var keys = (await storageProvider.GetAllKeysAsync()).ToFrozenSet();
+ var map = new Dictionary<ulong, List<string>>();
+ foreach (var key in keys) {
+ if (!key.StartsWith("old/")) continue;
+ var parts = key.Split('/');
+ if (parts.Length < 3) continue;
+ if (!ulong.TryParse(parts[1], out var checkpoint)) continue;
+ if (!map.ContainsKey(checkpoint)) map[checkpoint] = new();
+ map[checkpoint].Add(parts[2]);
+ }
+
+ return map.OrderBy(static x => x.Key).ToImmutableSortedDictionary(static x => x.Key, x => x.Value.ToFrozenSet());
+ }
+
+ private async Task<SyncResponse> MergeSyncs(SyncResponse oldSync, SyncResponse newSync, Dictionary<string, TimeSpan>? trace = null) {
+ // var sw = Stopwatch.StartNew();
+ oldSync.NextBatch = newSync.NextBatch;
+
+ void Trace(string key, TimeSpan span) {
+ if (trace is not null) {
+ lock (trace)
+ trace.Add(key, span);
+ }
+ }
+
+ var accountDataTask = Task.Run(() => {
+ var sw = Stopwatch.StartNew();
+ oldSync.AccountData = MergeEventList(oldSync.AccountData, newSync.AccountData);
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: AccountData took {sw.ElapsedMilliseconds}ms");
+ Trace("AccountData", sw.GetElapsedAndRestart());
+ });
+
+ var presenceTask = Task.Run(() => {
+ var sw = Stopwatch.StartNew();
+ oldSync.Presence = MergeEventListBy(oldSync.Presence, newSync.Presence,
+ static (oldState, newState) => oldState.Sender == newState.Sender && oldState.Type == newState.Type);
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: Presence took {sw.ElapsedMilliseconds}ms");
+ Trace("Presence", sw.GetElapsedAndRestart());
+ });
+
+ {
+ var sw = Stopwatch.StartNew();
+ // TODO: can this be cleaned up?
+ oldSync.DeviceOneTimeKeysCount ??= new();
+ if (newSync.DeviceOneTimeKeysCount is not null)
+ foreach (var (key, value) in newSync.DeviceOneTimeKeysCount)
+ oldSync.DeviceOneTimeKeysCount[key] = value;
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: DeviceOneTimeKeysCount took {sw.ElapsedMilliseconds}ms");
+ Trace("DeviceOneTimeKeysCount", sw.GetElapsedAndRestart());
+ }
+
+ var roomsTask = Task.Run(() => {
+ var sw = Stopwatch.StartNew();
+ if (newSync.Rooms is not null)
+ oldSync.Rooms = MergeRoomsDataStructure(oldSync.Rooms, newSync.Rooms, Trace);
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: Rooms took {sw.ElapsedMilliseconds}ms");
+ Trace("Rooms", sw.GetElapsedAndRestart());
+ });
+
+ var toDeviceTask = Task.Run(() => {
+ var sw = Stopwatch.StartNew();
+ // oldSync.ToDevice = MergeEventList(oldSync.ToDevice, newSync.ToDevice);
+ oldSync.ToDevice = AppendEventList(oldSync.ToDevice, newSync.ToDevice);
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: ToDevice took {sw.ElapsedMilliseconds}ms");
+ Trace("ToDevice", sw.GetElapsedAndRestart());
+ });
+
+ var deviceListsTask = Task.Run(() => {
+ var sw = Stopwatch.StartNew();
+ oldSync.DeviceLists ??= new SyncResponse.DeviceListsDataStructure();
+ oldSync.DeviceLists.Changed ??= [];
+ oldSync.DeviceLists.Left ??= [];
+ if (newSync.DeviceLists?.Changed is not null)
+ foreach (var s in newSync.DeviceLists.Changed!) {
+ oldSync.DeviceLists.Left.Remove(s);
+ oldSync.DeviceLists.Changed.Add(s);
+ }
+
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: DeviceLists.Changed took {sw.ElapsedMilliseconds}ms");
+ Trace("DeviceLists.Changed", sw.GetElapsedAndRestart());
+
+ if (newSync.DeviceLists?.Left is not null)
+ foreach (var s in newSync.DeviceLists.Left!) {
+ oldSync.DeviceLists.Changed.Remove(s);
+ oldSync.DeviceLists.Left.Add(s);
+ }
+
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: DeviceLists.Left took {sw.ElapsedMilliseconds}ms");
+ Trace("DeviceLists.Left", sw.GetElapsedAndRestart());
+ });
+
+ await Task.WhenAll(accountDataTask, presenceTask, roomsTask, toDeviceTask, deviceListsTask);
+
+ return oldSync;
+ }
+
+#region Merge rooms
+
+ private SyncResponse.RoomsDataStructure MergeRoomsDataStructure(SyncResponse.RoomsDataStructure? oldState, SyncResponse.RoomsDataStructure newState,
+ Action<string, TimeSpan> trace) {
+ var sw = Stopwatch.StartNew();
+ if (oldState is null) return newState;
+
+ if (newState.Join is { Count: > 0 })
+ if (oldState.Join is null)
+ oldState.Join = newState.Join;
+ else
+ foreach (var (key, value) in newState.Join)
+ if (!oldState.Join.TryAdd(key, value))
+ oldState.Join[key] = MergeJoinedRoomDataStructure(oldState.Join[key], value, key, trace);
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeRoomsDataStructure.Join took {sw.ElapsedMilliseconds}ms");
+ trace("MergeRoomsDataStructure.Join", sw.GetElapsedAndRestart());
+
+ if (newState.Invite is { Count: > 0 })
+ if (oldState.Invite is null)
+ oldState.Invite = newState.Invite;
+ else
+ foreach (var (key, value) in newState.Invite)
+ if (!oldState.Invite.TryAdd(key, value))
+ oldState.Invite[key] = MergeInvitedRoomDataStructure(oldState.Invite[key], value, key, trace);
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeRoomsDataStructure.Invite took {sw.ElapsedMilliseconds}ms");
+ trace("MergeRoomsDataStructure.Invite", sw.GetElapsedAndRestart());
+
+ if (newState.Leave is { Count: > 0 })
+ if (oldState.Leave is null)
+ oldState.Leave = newState.Leave;
+ else
+ foreach (var (key, value) in newState.Leave) {
+ if (!oldState.Leave.TryAdd(key, value))
+ oldState.Leave[key] = MergeLeftRoomDataStructure(oldState.Leave[key], value, key, trace);
+ if (oldState.Invite?.ContainsKey(key) ?? false) oldState.Invite.Remove(key);
+ if (oldState.Join?.ContainsKey(key) ?? false) oldState.Join.Remove(key);
+ }
+
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeRoomsDataStructure.Leave took {sw.ElapsedMilliseconds}ms");
+ trace("MergeRoomsDataStructure.Leave", sw.GetElapsedAndRestart());
+
return oldState;
}
- private SyncResponse.RoomsDataStructure.LeftRoomDataStructure MergeLeftRoomDataStructure(SyncResponse.RoomsDataStructure.LeftRoomDataStructure oldData,
- SyncResponse.RoomsDataStructure.LeftRoomDataStructure newData) {
- oldData.AccountData ??= new EventList();
- oldData.AccountData.Events ??= new List<StateEventResponse>();
- oldData.Timeline ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure();
- oldData.Timeline.Events ??= new List<StateEventResponse>();
- oldData.State ??= new EventList();
- oldData.State.Events ??= new List<StateEventResponse>();
+ private static SyncResponse.RoomsDataStructure.LeftRoomDataStructure MergeLeftRoomDataStructure(SyncResponse.RoomsDataStructure.LeftRoomDataStructure oldData,
+ SyncResponse.RoomsDataStructure.LeftRoomDataStructure newData, string roomId, Action<string, TimeSpan> trace) {
+ var sw = Stopwatch.StartNew();
- if (newData.AccountData?.Events is not null)
- oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? new List<StateEventResponse>());
+ oldData.AccountData = MergeEventList(oldData.AccountData, newData.AccountData);
+ trace($"LeftRoomDataStructure.AccountData/{roomId}", sw.GetElapsedAndRestart());
- if (newData.Timeline?.Events is not null)
- oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? new List<StateEventResponse>());
+ oldData.Timeline = AppendEventList(oldData.Timeline, newData.Timeline) as SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure
+ ?? throw new InvalidOperationException("Merged room timeline was not TimelineDataStructure");
oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited;
oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch;
+ trace($"LeftRoomDataStructure.Timeline/{roomId}", sw.GetElapsedAndRestart());
- if (newData.State?.Events is not null)
- oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? new List<StateEventResponse>());
+ oldData.State = MergeEventList(oldData.State, newData.State);
+ trace($"LeftRoomDataStructure.State/{roomId}", sw.GetElapsedAndRestart());
return oldData;
}
- private SyncResponse.RoomsDataStructure.InvitedRoomDataStructure MergeInvitedRoomDataStructure(SyncResponse.RoomsDataStructure.InvitedRoomDataStructure oldData,
- SyncResponse.RoomsDataStructure.InvitedRoomDataStructure newData) {
- oldData.InviteState ??= new EventList();
- oldData.InviteState.Events ??= new List<StateEventResponse>();
- if (newData.InviteState?.Events is not null)
- oldData.InviteState.Events.MergeStateEventLists(newData.InviteState?.Events ?? new List<StateEventResponse>());
+ private static SyncResponse.RoomsDataStructure.InvitedRoomDataStructure MergeInvitedRoomDataStructure(SyncResponse.RoomsDataStructure.InvitedRoomDataStructure oldData,
+ SyncResponse.RoomsDataStructure.InvitedRoomDataStructure newData, string roomId, Action<string, TimeSpan> trace) {
+ var sw = Stopwatch.StartNew();
+ oldData.InviteState = MergeEventList(oldData.InviteState, newData.InviteState);
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeInvitedRoomDataStructure.InviteState took {sw.ElapsedMilliseconds}ms for {roomId}");
+ trace($"InvitedRoomDataStructure.InviteState/{roomId}", sw.GetElapsedAndRestart());
return oldData;
}
- private SyncResponse.RoomsDataStructure.JoinedRoomDataStructure MergeJoinedRoomDataStructure(SyncResponse.RoomsDataStructure.JoinedRoomDataStructure oldData,
- SyncResponse.RoomsDataStructure.JoinedRoomDataStructure newData) {
- oldData.AccountData ??= new EventList();
- oldData.AccountData.Events ??= new List<StateEventResponse>();
- oldData.Timeline ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure();
- oldData.Timeline.Events ??= new List<StateEventResponse>();
- oldData.State ??= new EventList();
- oldData.State.Events ??= new List<StateEventResponse>();
- oldData.Ephemeral ??= new EventList();
- oldData.Ephemeral.Events ??= new List<StateEventResponse>();
+ private static SyncResponse.RoomsDataStructure.JoinedRoomDataStructure MergeJoinedRoomDataStructure(SyncResponse.RoomsDataStructure.JoinedRoomDataStructure oldData,
+ SyncResponse.RoomsDataStructure.JoinedRoomDataStructure newData, string roomId, Action<string, TimeSpan> trace) {
+ var sw = Stopwatch.StartNew();
+
+ oldData.AccountData = MergeEventList(oldData.AccountData, newData.AccountData);
- if (newData.AccountData?.Events is not null)
- oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? new List<StateEventResponse>());
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeJoinedRoomDataStructure.AccountData took {sw.ElapsedMilliseconds}ms for {roomId}");
+ trace($"JoinedRoomDataStructure.AccountData/{roomId}", sw.GetElapsedAndRestart());
- if (newData.Timeline?.Events is not null)
- oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? new List<StateEventResponse>());
+ oldData.Timeline = AppendEventList(oldData.Timeline, newData.Timeline) as SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure
+ ?? throw new InvalidOperationException("Merged room timeline was not TimelineDataStructure");
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 List<StateEventResponse>());
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeJoinedRoomDataStructure.Timeline took {sw.ElapsedMilliseconds}ms for {roomId}");
+ trace($"JoinedRoomDataStructure.Timeline/{roomId}", sw.GetElapsedAndRestart());
- if (newData.Ephemeral?.Events is not null)
- oldData.Ephemeral.Events.MergeStateEventLists(newData.Ephemeral?.Events ?? new List<StateEventResponse>());
+ oldData.State = MergeEventList(oldData.State, newData.State);
+
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeJoinedRoomDataStructure.State took {sw.ElapsedMilliseconds}ms for {roomId}");
+ trace($"JoinedRoomDataStructure.State/{roomId}", sw.GetElapsedAndRestart());
+
+ oldData.Ephemeral = MergeEventList(oldData.Ephemeral, newData.Ephemeral);
+
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeJoinedRoomDataStructure.Ephemeral took {sw.ElapsedMilliseconds}ms for {roomId}");
+ trace($"JoinedRoomDataStructure.Ephemeral/{roomId}", sw.GetElapsedAndRestart());
oldData.UnreadNotifications ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.UnreadNotificationsDataStructure();
oldData.UnreadNotifications.HighlightCount = newData.UnreadNotifications?.HighlightCount ?? oldData.UnreadNotifications.HighlightCount;
oldData.UnreadNotifications.NotificationCount = newData.UnreadNotifications?.NotificationCount ?? oldData.UnreadNotifications.NotificationCount;
- oldData.Summary ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.SummaryDataStructure {
- Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes,
- JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount,
- InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount
- };
- 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;
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeJoinedRoomDataStructure.UnreadNotifications took {sw.ElapsedMilliseconds}ms for {roomId}");
+ trace($"JoinedRoom$DataStructure.UnreadNotifications/{roomId}", sw.GetElapsedAndRestart());
+
+ if (oldData.Summary is null)
+ oldData.Summary = newData.Summary;
+ else {
+ 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;
+ }
+
+ if (sw.ElapsedMilliseconds > 100) Console.WriteLine($"WARN: MergeJoinedRoomDataStructure.Summary took {sw.ElapsedMilliseconds}ms for {roomId}");
+ trace($"JoinedRoomDataStructure.Summary/{roomId}", sw.GetElapsedAndRestart());
return oldData;
}
#endregion
+
+ private static EventList? MergeEventList(EventList? oldState, EventList? newState) {
+ if (newState is null) return oldState;
+ if (oldState is null) {
+ return newState;
+ }
+
+ if (newState.Events is null) return oldState;
+ if (oldState.Events is null) {
+ oldState.Events = newState.Events;
+ return oldState;
+ }
+
+ // oldState.Events.MergeStateEventLists(newState.Events);
+ oldState = MergeEventListBy(oldState, newState, static (oldEvt, newEvt) => oldEvt.Type == newEvt.Type && oldEvt.StateKey == newEvt.StateKey);
+ return oldState;
+ }
+
+ private static EventList? MergeEventListBy(EventList? oldState, EventList? newState, Func<StateEventResponse, StateEventResponse, bool> comparer) {
+ if (newState is null) return oldState;
+ if (oldState is null) {
+ return newState;
+ }
+
+ if (newState.Events is null) return oldState;
+ if (oldState.Events is null) {
+ oldState.Events = newState.Events;
+ return oldState;
+ }
+
+ oldState.Events.ReplaceBy(newState.Events, comparer);
+ return oldState;
+ }
+
+ private static EventList? AppendEventList(EventList? oldState, EventList? newState) {
+ if (newState is null) return oldState;
+ if (oldState is null) {
+ return newState;
+ }
+
+ if (newState.Events is null) return oldState;
+ if (oldState.Events is null) {
+ oldState.Events = newState.Events;
+ return oldState;
+ }
+
+ oldState.Events.AddRange(newState.Events);
+ return oldState;
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
index 6be49b9..5fd3311 100644
--- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
+++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
@@ -5,8 +5,8 @@ using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Web;
using ArcaneLibs.Extensions;
-using LibMatrix.EventTypes.Spec.State;
-using LibMatrix.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
using LibMatrix.Filters;
using LibMatrix.Helpers;
using LibMatrix.Homeservers.Extensions.NamedCaches;
@@ -14,7 +14,6 @@ using LibMatrix.Responses;
using LibMatrix.RoomTypes;
using LibMatrix.Services;
using LibMatrix.Utilities;
-using Microsoft.Extensions.Logging.Abstractions;
namespace LibMatrix.Homeservers;
@@ -41,14 +40,14 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
public string UserId => WhoAmI.UserId;
public string UserLocalpart => UserId.Split(":")[0][1..];
public string ServerName => UserId.Split(":", 2)[1];
+ public string BaseUrl => ClientHttpClient.BaseAddress!.ToString().TrimEnd('/');
[JsonIgnore]
public string AccessToken { get; set; }
- public HsNamedCaches NamedCaches { get; set; } = null!;
+ public HsNamedCaches NamedCaches { get; set; }
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);
}
@@ -171,8 +170,9 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
try {
return await GetAccountDataAsync<T>(key);
}
- catch (Exception e) {
- return default;
+ catch (MatrixException e) {
+ if (e is { ErrorCode: MatrixException.ErrorCodes.M_NOT_FOUND }) return default;
+ throw;
}
}
@@ -186,6 +186,16 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
#endregion
+#region MSC 4133
+
+ public async Task UpdateProfilePropertyAsync(string name, object? value) {
+ var caps = await GetCapabilitiesAsync();
+ if (caps is null) throw new Exception("Failed to get capabilities");
+ }
+
+#endregion
+
+ [Obsolete("This method assumes no support for MSC 4069 and MSC 4133")]
public async Task UpdateProfileAsync(UserProfileResponse? newProfile, bool preserveCustomRoomProfile = true) {
if (newProfile is null) return;
Console.WriteLine($"Updating profile for {WhoAmI.UserId} to {newProfile.ToJson(ignoreNull: true)} (preserving room profiles: {preserveCustomRoomProfile})");
@@ -377,11 +387,11 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
/// <returns>All account data.</returns>
/// <exception cref="Exception"></exception>
public async Task<EventList?> EnumerateAccountData() {
- var syncHelper = new SyncHelper(this);
- syncHelper.FilterId = await NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetAccountData);
+ var syncHelper = new SyncHelper(this) {
+ FilterId = await NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetAccountData)
+ };
var resp = await syncHelper.SyncAsync();
- if (resp is null) throw new Exception("Sync failed");
- return resp.AccountData;
+ return resp?.AccountData ?? throw new Exception("Sync failed");
}
private Dictionary<string, string>? _namedFilterCache;
@@ -410,22 +420,22 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
#region Authenticated Media
// TODO: implement /_matrix/client/v1/media/config when it's actually useful - https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv1mediaconfig
+ private bool? _serverSupportsAuthMedia;
- private (string ServerName, string MediaId) ParseMxcUri(string mxcUri) {
- if (!mxcUri.StartsWith("mxc://")) throw new ArgumentException("Matrix Content URIs must start with 'mxc://'", nameof(mxcUri));
- var parts = mxcUri[6..].Split('/');
- if (parts.Length != 2) throw new ArgumentException($"Invalid Matrix Content URI '{mxcUri}' passed! Matrix Content URIs must exist of only 2 parts!", nameof(mxcUri));
- return (parts[0], parts[1]);
- }
+ public async Task<string> GetMediaUrlAsync(MxcUri mxcUri, string? filename = null, int? timeout = null) {
+ if (_serverSupportsAuthMedia == true) return mxcUri.ToDownloadUri(BaseUrl, filename, timeout);
+ if (_serverSupportsAuthMedia == false) return mxcUri.ToLegacyDownloadUri(BaseUrl, filename, timeout);
- public async Task<Stream> GetMediaStreamAsync(string mxcUri, string? filename = null, int? timeout = null) {
- var (serverName, mediaId) = ParseMxcUri(mxcUri);
try {
- var uri = $"/_matrix/client/v1/media/download/{serverName}/{mediaId}";
- if (!string.IsNullOrWhiteSpace(filename)) uri += $"/{HttpUtility.UrlEncode(filename)}";
- if (timeout is not null) uri += $"?timeout_ms={timeout}";
- var res = await ClientHttpClient.GetAsync(uri);
- return await res.Content.ReadAsStreamAsync();
+ // Console.WriteLine($"Trying authenticated media URL: {uri}");
+ var res = await ClientHttpClient.SendAsync(new() {
+ Method = HttpMethod.Head,
+ RequestUri = (new Uri(mxcUri.ToDownloadUri(BaseUrl, filename, timeout), string.IsNullOrWhiteSpace(BaseUrl) ? UriKind.Relative : UriKind.Absolute))
+ });
+ if (res.IsSuccessStatusCode) {
+ _serverSupportsAuthMedia = true;
+ return mxcUri.ToDownloadUri(BaseUrl, filename, timeout);
+ }
}
catch (MatrixException e) {
if (e is not { ErrorCode: "M_UNKNOWN" }) throw;
@@ -433,11 +443,15 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
//fallback to legacy media
try {
- var uri = $"/_matrix/media/v1/download/{serverName}/{mediaId}";
- if (!string.IsNullOrWhiteSpace(filename)) uri += $"/{HttpUtility.UrlEncode(filename)}";
- if (timeout is not null) uri += $"?timeout_ms={timeout}";
- var res = await ClientHttpClient.GetAsync(uri);
- return await res.Content.ReadAsStreamAsync();
+ // Console.WriteLine($"Trying legacy media URL: {uri}");
+ var res = await ClientHttpClient.SendAsync(new() {
+ Method = HttpMethod.Head,
+ RequestUri = new(mxcUri.ToLegacyDownloadUri(BaseUrl, filename, timeout), string.IsNullOrWhiteSpace(BaseUrl) ? UriKind.Relative : UriKind.Absolute)
+ });
+ if (res.IsSuccessStatusCode) {
+ _serverSupportsAuthMedia = false;
+ return mxcUri.ToLegacyDownloadUri(BaseUrl, filename, timeout);
+ }
}
catch (MatrixException e) {
if (e is not { ErrorCode: "M_UNKNOWN" }) throw;
@@ -445,19 +459,25 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
throw new LibMatrixException() {
ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED,
- Error = "Failed to download media"
+ Error = "Failed to get media URL"
};
- // return default;
}
- public async Task<Stream> GetThumbnailStreamAsync(string mxcUri, int width, int height, string? method = null, int? timeout = null) {
- var (serverName, mediaId) = ParseMxcUri(mxcUri);
+ public async Task<Stream> GetMediaStreamAsync(string mxcUri, string? filename = null, int? timeout = null) {
+ var uri = await GetMediaUrlAsync(mxcUri, filename, timeout);
+ var res = await ClientHttpClient.GetAsync(uri);
+ return await res.Content.ReadAsStreamAsync();
+ }
+
+ public async Task<Stream> GetThumbnailStreamAsync(MxcUri mxcUri, int width, int height, string? method = null, int? timeout = null) {
+ var (serverName, mediaId) = mxcUri;
+
try {
var uri = new Uri($"/_matrix/client/v1/thumbnail/{serverName}/{mediaId}");
uri = uri.AddQuery("width", width.ToString());
uri = uri.AddQuery("height", height.ToString());
if (!string.IsNullOrWhiteSpace(method)) uri = uri.AddQuery("method", method);
- if (timeout is not null) uri = uri.AddQuery("timeout_ms", timeout.ToString());
+ if (timeout is not null) uri = uri.AddQuery("timeout_ms", timeout.ToString()!);
var res = await ClientHttpClient.GetAsync(uri.ToString());
return await res.Content.ReadAsStreamAsync();
@@ -468,11 +488,11 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
//fallback to legacy media
try {
- var uri = new Uri($"/_matrix/media/v1/thumbnail/{serverName}/{mediaId}");
+ var uri = new Uri($"/_matrix/media/v3/thumbnail/{serverName}/{mediaId}");
uri = uri.AddQuery("width", width.ToString());
uri = uri.AddQuery("height", height.ToString());
if (!string.IsNullOrWhiteSpace(method)) uri = uri.AddQuery("method", method);
- if (timeout is not null) uri = uri.AddQuery("timeout_ms", timeout.ToString());
+ if (timeout is not null) uri = uri.AddQuery("timeout_ms", timeout.ToString()!);
var res = await ClientHttpClient.GetAsync(uri.ToString());
return await res.Content.ReadAsStreamAsync();
@@ -496,16 +516,16 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
catch (MatrixException e) {
if (e is not { ErrorCode: "M_UNRECOGNIZED" }) throw;
}
-
+
//fallback to legacy media
try {
- var res = await ClientHttpClient.GetAsync($"/_matrix/media/v1/preview_url?url={HttpUtility.UrlEncode(url)}");
+ var res = await ClientHttpClient.GetAsync($"/_matrix/media/v3/preview_url?url={HttpUtility.UrlEncode(url)}");
return await res.Content.ReadFromJsonAsync<Dictionary<string, JsonValue>>();
}
catch (MatrixException e) {
if (e is not { ErrorCode: "M_UNRECOGNIZED" }) throw;
}
-
+
throw new LibMatrixException() {
ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED,
Error = "Failed to download URL preview"
@@ -513,4 +533,86 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
}
#endregion
+
+ public Task ReportRoomAsync(string roomId, string reason) =>
+ ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{roomId}/report", new {
+ reason
+ });
+
+ public async Task ReportRoomEventAsync(string roomId, string eventId, string reason, int score = 0, bool ignoreSender = false) {
+ await ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{roomId}/report/{eventId}", new {
+ reason,
+ score
+ });
+
+ if (ignoreSender) {
+ var eventContent = await GetRoom(roomId).GetEventAsync(eventId);
+ var sender = eventContent.Sender;
+ await IgnoreUserAsync(sender);
+ }
+ }
+
+ public async Task ReportUserAsync(string userId, string reason, bool ignore = false) {
+ await ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/users/{userId}/report", new {
+ reason
+ });
+
+ if (ignore) {
+ await IgnoreUserAsync(userId);
+ }
+ }
+
+ public async Task<IgnoredUserListEventContent> GetIgnoredUserListAsync() {
+ return await GetAccountDataOrNullAsync<IgnoredUserListEventContent>(IgnoredUserListEventContent.EventId) ?? new();
+ }
+
+ public async Task IgnoreUserAsync(string userId, IgnoredUserListEventContent.IgnoredUserContent? content = null) {
+ content ??= new();
+
+ var ignoredUserList = await GetIgnoredUserListAsync();
+ ignoredUserList.IgnoredUsers.TryAdd(userId, content);
+ await SetAccountDataAsync(IgnoredUserListEventContent.EventId, ignoredUserList);
+ }
+
+ private class CapabilitiesResponse {
+ [JsonPropertyName("capabilities")]
+ public Dictionary<string, object>? Capabilities { get; set; }
+ }
+
+#region Room Directory/aliases
+
+ public async Task SetRoomAliasAsync(string roomAlias, string roomId) {
+ var resp = await ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/directory/room/{HttpUtility.UrlEncode(roomAlias)}", new RoomIdResponse() {
+ RoomId = roomId
+ });
+ if (!resp.IsSuccessStatusCode) {
+ Console.WriteLine($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}");
+ throw new InvalidDataException($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}");
+ }
+ }
+
+ public async Task DeleteRoomAliasAsync(string roomAlias) {
+ var resp = await ClientHttpClient.DeleteAsync("/_matrix/client/v3/directory/room/" + HttpUtility.UrlEncode(roomAlias));
+ if (!resp.IsSuccessStatusCode) {
+ Console.WriteLine($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}");
+ throw new InvalidDataException($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}");
+ }
+ }
+
+ public async Task<RoomAliasesResponse> GetLocalRoomAliasesAsync(string roomId) {
+ var resp = await ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{HttpUtility.UrlEncode(roomId)}/aliases");
+ if (!resp.IsSuccessStatusCode) {
+ Console.WriteLine($"Failed to get room aliases: {await resp.Content.ReadAsStringAsync()}");
+ throw new InvalidDataException($"Failed to get room aliases: {await resp.Content.ReadAsStringAsync()}");
+ }
+
+ return await resp.Content.ReadFromJsonAsync<RoomAliasesResponse>() ?? throw new Exception("Failed to get room aliases?");
+ }
+
+ public class RoomAliasesResponse {
+ [JsonPropertyName("aliases")]
+ public required List<string> Aliases { get; set; }
+ }
+
+#endregion
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverHSE.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverHSE.cs
new file mode 100644
index 0000000..1cc8ca2
--- /dev/null
+++ b/LibMatrix/Homeservers/AuthenticatedHomeserverHSE.cs
@@ -0,0 +1,16 @@
+using LibMatrix.Homeservers.ImplementationDetails.Synapse;
+using LibMatrix.Responses;
+using LibMatrix.Services;
+
+namespace LibMatrix.Homeservers;
+
+public class AuthenticatedHomeserverHSE : AuthenticatedHomeserverGeneric {
+ public AuthenticatedHomeserverHSE(string serverName, HomeserverResolverService.WellKnownUris wellKnownUris, string? proxy, string accessToken) : base(serverName,
+ wellKnownUris, proxy, accessToken) { }
+
+ public Task<Dictionary<string, LoginResponse>> GetExternalProfilesAsync() =>
+ ClientHttpClient.GetFromJsonAsync<Dictionary<string, LoginResponse>>("/_hse/client/v1/external_profiles");
+
+ public Task SetExternalProfile(string sessionName, LoginResponse session) =>
+ ClientHttpClient.PutAsJsonAsync($"/_hse/client/v1/external_profiles/{sessionName}", session);
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverSynapse.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverSynapse.cs
index 83ebf20..1c0a656 100644
--- a/LibMatrix/Homeservers/AuthenticatedHomeserverSynapse.cs
+++ b/LibMatrix/Homeservers/AuthenticatedHomeserverSynapse.cs
@@ -1,7 +1,4 @@
-using ArcaneLibs.Extensions;
-using LibMatrix.Filters;
using LibMatrix.Homeservers.ImplementationDetails.Synapse;
-using LibMatrix.Responses.Admin;
using LibMatrix.Services;
namespace LibMatrix.Homeservers;
diff --git a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
index 622eef6..1f62637 100644
--- a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
+++ b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
@@ -3,35 +3,72 @@ namespace LibMatrix.Homeservers.Extensions.NamedCaches;
public class NamedCache<T>(AuthenticatedHomeserverGeneric hs, string name) where T : class {
private Dictionary<string, T>? _cache = new();
private DateTime _expiry = DateTime.MinValue;
-
+ private SemaphoreSlim _lock = new(1, 1);
+
+ public TimeSpan ExpiryTime { get; set; } = TimeSpan.FromMinutes(5);
+ public DateTime GetCurrentExpiryTime() => _expiry;
+
+ /// <summary>
+ /// Update the cached map with the latest data from the homeserver.
+ /// </summary>
+ /// <returns>The updated data</returns>
public async Task<Dictionary<string, T>> ReadCacheMapAsync() {
- _cache = await hs.GetAccountDataOrNullAsync<Dictionary<string, T>>(name);
+ try {
+ _cache = await hs.GetAccountDataAsync<Dictionary<string, T>>(name);
+ }
+ catch (MatrixException e) {
+ if (e is { ErrorCode: MatrixException.ErrorCodes.M_NOT_FOUND })
+ _cache = [];
+ else throw;
+ }
return _cache ?? new();
}
-
- public async Task<Dictionary<string,T>> ReadCacheMapCachedAsync() {
+
+ public async Task<Dictionary<string, T>> ReadCacheMapCachedAsync() {
+ await _lock.WaitAsync();
if (_expiry < DateTime.Now || _cache == null) {
_cache = await ReadCacheMapAsync();
- _expiry = DateTime.Now.AddMinutes(5);
+ _expiry = DateTime.Now.Add(ExpiryTime);
}
+ _lock.Release();
+
return _cache;
}
-
- public virtual async Task<T?> GetValueAsync(string key) {
- return (await ReadCacheMapCachedAsync()).GetValueOrDefault(key);
+
+ public virtual async Task<T?> GetValueAsync(string key, bool useCache = true) {
+ return (await (useCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync())).GetValueOrDefault(key);
}
-
- public virtual async Task<T> SetValueAsync(string key, T value) {
- var cache = await ReadCacheMapCachedAsync();
+
+ public virtual async Task<T> SetValueAsync(string key, T value, bool unsafeUseCache = false) {
+ if (!unsafeUseCache)
+ await _lock.WaitAsync();
+ var cache = await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync());
cache[key] = value;
await hs.SetAccountDataAsync(name, cache);
+ if (!unsafeUseCache)
+ _lock.Release();
+
return value;
}
-
- public virtual async Task<T> GetOrSetValueAsync(string key, Func<Task<T>> value) {
- return (await ReadCacheMapCachedAsync()).GetValueOrDefault(key) ?? await SetValueAsync(key, await value());
+
+ public virtual async Task<T> RemoveValueAsync(string key, bool unsafeUseCache = false) {
+ if (!unsafeUseCache)
+ await _lock.WaitAsync();
+ var cache = await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync());
+ var removedValue = cache[key];
+ cache.Remove(key);
+ await hs.SetAccountDataAsync(name, cache);
+
+ if (!unsafeUseCache)
+ _lock.Release();
+
+ return removedValue;
+ }
+
+ public virtual async Task<T> GetOrSetValueAsync(string key, Func<Task<T>> value, bool unsafeUseCache = false) {
+ return (await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync())).GetValueOrDefault(key) ?? await SetValueAsync(key, await value());
}
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFilterCache.cs b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFilterCache.cs
index 76533a4..e3c5943 100644
--- a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFilterCache.cs
+++ b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFilterCache.cs
@@ -1,3 +1,5 @@
+using System.Text.Json.Nodes;
+using ArcaneLibs.Extensions;
using LibMatrix.Filters;
using LibMatrix.Utilities;
@@ -16,7 +18,13 @@ public class NamedFilterCache(AuthenticatedHomeserverGeneric hs) : NamedCache<st
public async Task<string> GetOrSetValueAsync(string key, SyncFilter? filter = null) {
var existingValue = await GetValueAsync(key);
if (existingValue != null) {
- return existingValue;
+ try {
+ var existingFilter = await hs.GetFilterAsync(existingValue);
+ return existingValue;
+ }
+ catch {
+ // ignored
+ }
}
if (filter is null) {
diff --git a/LibMatrix/Homeservers/FederationClient.cs b/LibMatrix/Homeservers/FederationClient.cs
index 22653e4..a2cb12d 100644
--- a/LibMatrix/Homeservers/FederationClient.cs
+++ b/LibMatrix/Homeservers/FederationClient.cs
@@ -1,7 +1,7 @@
using System.Text.Json.Serialization;
using LibMatrix.Extensions;
using LibMatrix.Services;
-using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.VisualBasic.CompilerServices;
namespace LibMatrix.Homeservers;
@@ -14,10 +14,74 @@ public class FederationClient {
if (proxy is not null) HttpClient.DefaultRequestHeaders.Add("MXAE_UPSTREAM", federationEndpoint);
}
- public MatrixHttpClient HttpClient { get; set; } = null!;
- public HomeserverResolverService.WellKnownUris WellKnownUris { get; set; } = null!;
+ public MatrixHttpClient HttpClient { get; set; }
+ public HomeserverResolverService.WellKnownUris WellKnownUris { get; set; }
public async Task<ServerVersionResponse> GetServerVersionAsync() => await HttpClient.GetFromJsonAsync<ServerVersionResponse>("/_matrix/federation/v1/version");
+ public async Task<ServerKeysResponse> GetServerKeysAsync() => await HttpClient.GetFromJsonAsync<ServerKeysResponse>("/_matrix/key/v2/server");
+}
+
+public class ServerKeysResponse {
+ [JsonPropertyName("server_name")]
+ public string ServerName { get; set; }
+
+ [JsonPropertyName("valid_until_ts")]
+ public ulong ValidUntilTs { get; set; }
+
+ [JsonIgnore]
+ public DateTime ValidUntil {
+ get => DateTimeOffset.FromUnixTimeMilliseconds((long)ValidUntilTs).DateTime;
+ set => ValidUntilTs = (ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds();
+ }
+
+ [JsonPropertyName("verify_keys")]
+ public Dictionary<string, CurrentVerifyKey> VerifyKeys { get; set; } = new();
+
+ [JsonIgnore]
+ public Dictionary<VersionedKeyId, CurrentVerifyKey> VerifyKeysById {
+ get => VerifyKeys.ToDictionary(key => (VersionedKeyId)key.Key, key => key.Value);
+ set => VerifyKeys = value.ToDictionary(key => (string)key.Key, key => key.Value);
+ }
+
+ [JsonPropertyName("old_verify_keys")]
+ public Dictionary<string, ExpiredVerifyKey> OldVerifyKeys { get; set; } = new();
+
+ [JsonIgnore]
+ public Dictionary<VersionedKeyId, ExpiredVerifyKey> OldVerifyKeysById {
+ get => OldVerifyKeys.ToDictionary(key => (VersionedKeyId)key.Key, key => key.Value);
+ set => OldVerifyKeys = value.ToDictionary(key => (string)key.Key, key => key.Value);
+ }
+
+ public class VersionedKeyId {
+ public required string Algorithm { get; set; }
+ public required string KeyId { get; set; }
+
+ public static implicit operator VersionedKeyId(string key) {
+ var parts = key.Split(':', 2);
+ if (parts.Length != 2) throw new ArgumentException("Invalid key format. Expected 'algorithm:keyId'.", nameof(key));
+ return new VersionedKeyId { Algorithm = parts[0], KeyId = parts[1] };
+ }
+
+ public static implicit operator string(VersionedKeyId key) => $"{key.Algorithm}:{key.KeyId}";
+ public static implicit operator (string, string)(VersionedKeyId key) => (key.Algorithm, key.KeyId);
+ public static implicit operator VersionedKeyId((string algorithm, string keyId) key) => (key.algorithm, key.keyId);
+ }
+
+ public class CurrentVerifyKey {
+ [JsonPropertyName("key")]
+ public string Key { get; set; }
+ }
+
+ public class ExpiredVerifyKey : CurrentVerifyKey {
+ [JsonPropertyName("expired_ts")]
+ public ulong ExpiredTs { get; set; }
+
+ [JsonIgnore]
+ public DateTime Expired {
+ get => DateTimeOffset.FromUnixTimeMilliseconds((long)ExpiredTs).DateTime;
+ set => ExpiredTs = (ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds();
+ }
+ }
}
public class ServerVersionResponse {
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs
new file mode 100644
index 0000000..c34ad7c
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs
@@ -0,0 +1,27 @@
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
+
+public class SynapseAdminLocalEventReportQueryFilter {
+ public string UserIdContains { get; set; } = "";
+ public string NameContains { get; set; } = "";
+ public string CanonicalAliasContains { get; set; } = "";
+ public string VersionContains { get; set; } = "";
+ public string CreatorContains { get; set; } = "";
+ public string EncryptionContains { get; set; } = "";
+ public string JoinRulesContains { get; set; } = "";
+ public string GuestAccessContains { get; set; } = "";
+ public string HistoryVisibilityContains { get; set; } = "";
+
+ public bool Federatable { get; set; } = true;
+ public bool Public { get; set; } = true;
+
+ public int JoinedMembersGreaterThan { get; set; }
+ public int JoinedMembersLessThan { get; set; } = int.MaxValue;
+
+ public int JoinedLocalMembersGreaterThan { get; set; }
+ public int JoinedLocalMembersLessThan { get; set; } = int.MaxValue;
+ public int StateEventsGreaterThan { get; set; }
+ public int StateEventsLessThan { get; set; } = int.MaxValue;
+
+ public bool CheckFederation { get; set; }
+ public bool CheckPublic { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Filters/LocalRoomQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs
index b3bd4c0..b8929a0 100644
--- a/LibMatrix/Filters/LocalRoomQueryFilter.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs
@@ -1,6 +1,6 @@
-namespace LibMatrix.Filters;
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
-public class LocalRoomQueryFilter {
+public class SynapseAdminLocalRoomQueryFilter {
public string RoomIdContains { get; set; } = "";
public string NameContains { get; set; } = "";
public string CanonicalAliasContains { get; set; } = "";
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs
new file mode 100644
index 0000000..5a4acf7
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs
@@ -0,0 +1,5 @@
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
+
+public class SynapseAdminLocalUserQueryFilter {
+
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs
deleted file mode 100644
index f4c927a..0000000
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
-
-public class AdminRoomDeleteRequest {
- [JsonPropertyName("new_room_user_id")]
- public string? NewRoomUserId { get; set; }
-
- [JsonPropertyName("room_name")]
- public string? RoomName { get; set; }
-
- [JsonPropertyName("block")]
- public bool Block { get; set; }
-
- [JsonPropertyName("purge")]
- public bool Purge { get; set; }
-
- [JsonPropertyName("message")]
- public string? Message { get; set; }
-
- [JsonPropertyName("force_purge")]
- public bool ForcePurge { get; set; }
-}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs
new file mode 100644
index 0000000..197fd5d
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRegistrationTokenUpdateRequest {
+ [JsonPropertyName("uses_allowed")]
+ public int? UsesAllowed { get; set; }
+
+ [JsonPropertyName("expiry_time")]
+ public long? ExpiryTime { get; set; }
+
+ [JsonIgnore]
+ public DateTime? ExpiresAt {
+ get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime : null;
+ set => ExpiryTime = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+ }
+
+ [JsonIgnore]
+ public TimeSpan? ExpiresAfter {
+ get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime - DateTimeOffset.Now : null;
+ set => ExpiryTime = value.HasValue ? (DateTimeOffset.Now + value.Value).ToUnixTimeMilliseconds() : null;
+ }
+}
+
+public class SynapseAdminRegistrationTokenCreateRequest : SynapseAdminRegistrationTokenUpdateRequest {
+ [JsonPropertyName("token")]
+ public string? Token { get; set; }
+
+ [JsonPropertyName("length")]
+ public int? Length { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs
new file mode 100644
index 0000000..c2b2fac
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs
@@ -0,0 +1,60 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
+
+public class SynapseAdminRoomDeleteRequest {
+ [JsonPropertyName("new_room_user_id")]
+ public string? NewRoomUserId { get; set; }
+
+ [JsonPropertyName("room_name")]
+ public string? RoomName { get; set; }
+
+ [JsonPropertyName("block")]
+ public bool Block { get; set; }
+
+ [JsonPropertyName("purge")]
+ public bool Purge { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("force_purge")]
+ public bool ForcePurge { get; set; }
+}
+
+public class SynapseAdminRoomDeleteResponse {
+ [JsonPropertyName("delete_id")]
+ public string DeleteId { get; set; } = null!;
+}
+
+public class SynapseAdminRoomDeleteStatusList {
+ [JsonPropertyName("results")]
+ public List<SynapseAdminRoomDeleteStatus> Results { get; set; }
+}
+
+public class SynapseAdminRoomDeleteStatus {
+ public const string Scheduled = "scheduled";
+ public const string Active = "active";
+ public const string Complete = "complete";
+ public const string Failed = "failed";
+
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = null!;
+
+ [JsonPropertyName("shutdown_room")]
+ public RoomShutdownInfo ShutdownRoom { get; set; }
+
+ public class RoomShutdownInfo {
+ [JsonPropertyName("kicked_users")]
+ public List<string>? KickedUsers { get; set; }
+
+ [JsonPropertyName("failed_to_kick_users")]
+ public List<string>? FailedToKickUsers { get; set; }
+
+ [JsonPropertyName("local_aliases")]
+ public List<string>? LocalAliasses { get; set; }
+
+ [JsonPropertyName("new_room_id")]
+ public string? NewRoomId { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs
new file mode 100644
index 0000000..2394b98
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs
@@ -0,0 +1,28 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminBackgroundUpdateStatusResponse {
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
+
+ [JsonPropertyName("current_updates")]
+ public Dictionary<string, BackgroundUpdateInfo> CurrentUpdates { get; set; }
+
+ public class BackgroundUpdateInfo {
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ [JsonPropertyName("total_item_count")]
+ public int TotalItemCount { get; set; }
+
+ [JsonPropertyName("total_duration_ms")]
+ public double TotalDurationMs { get; set; }
+
+ [JsonPropertyName("average_items_per_ms")]
+ public double AverageItemsPerMs { get; set; }
+
+ [JsonIgnore]
+ public TimeSpan TotalDuration => TimeSpan.FromMilliseconds(TotalDurationMs);
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs
new file mode 100644
index 0000000..646a4b5
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs
@@ -0,0 +1,56 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminDestinationListResult : SynapseNextTokenTotalCollectionResult {
+ [JsonPropertyName("destinations")]
+ public List<SynapseAdminDestinationListResultDestination> Destinations { get; set; } = new();
+
+ public class SynapseAdminDestinationListResultDestination {
+ [JsonPropertyName("destination")]
+ public string Destination { get; set; }
+
+ [JsonPropertyName("retry_last_ts")]
+ public long RetryLastTs { get; set; }
+
+ [JsonPropertyName("retry_interval")]
+ public long RetryInterval { get; set; }
+
+ [JsonPropertyName("failure_ts")]
+ public long? FailureTs { get; set; }
+
+ [JsonPropertyName("last_successful_stream_ordering")]
+ public long? LastSuccessfulStreamOrdering { get; set; }
+
+ [JsonIgnore]
+ public DateTime? FailureTsDateTime {
+ get => FailureTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(FailureTs.Value).DateTime : null;
+ set => FailureTs = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+ }
+
+ [JsonIgnore]
+ public DateTime? RetryLastTsDateTime {
+ get => DateTimeOffset.FromUnixTimeMilliseconds(RetryLastTs).DateTime;
+ set => RetryLastTs = new DateTimeOffset(value.Value).ToUnixTimeMilliseconds();
+ }
+
+ [JsonIgnore]
+ public TimeSpan RetryIntervalTimeSpan {
+ get => TimeSpan.FromMilliseconds(RetryInterval);
+ set => RetryInterval = (long)value.TotalMilliseconds;
+ }
+ }
+}
+
+public class SynapseAdminDestinationRoomListResult : SynapseNextTokenTotalCollectionResult {
+ [JsonPropertyName("rooms")]
+ public List<SynapseAdminDestinationRoomListResultRoom> Rooms { get; set; } = new();
+
+ public class SynapseAdminDestinationRoomListResultRoom {
+ [JsonPropertyName("room_id")]
+ public string RoomId { get; set; }
+
+ [JsonPropertyName("stream_ordering")]
+ public int StreamOrdering { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
new file mode 100644
index 0000000..10fc039
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
@@ -0,0 +1,169 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs;
+using ArcaneLibs.Attributes;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes;
+using LibMatrix.Extensions;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminEventReportListResult : SynapseNextTokenTotalCollectionResult {
+ [JsonPropertyName("event_reports")]
+ public List<SynapseAdminEventReportListResultReport> Reports { get; set; } = new();
+
+ public class SynapseAdminEventReportListResultReport {
+ [JsonPropertyName("event_id")]
+ public string EventId { get; set; }
+
+ [JsonPropertyName("id")]
+ public string Id { get; set; }
+
+ [JsonPropertyName("reason")]
+ public string? Reason { get; set; }
+
+ [JsonPropertyName("score")]
+ public int? Score { get; set; }
+
+ [JsonPropertyName("received_ts")]
+ public long ReceivedTs { get; set; }
+
+ [JsonPropertyName("canonical_alias")]
+ public string? CanonicalAlias { get; set; }
+
+ [JsonPropertyName("room_id")]
+ public string RoomId { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("sender")]
+ public string Sender { get; set; }
+
+ [JsonPropertyName("user_id")]
+ public string UserId { get; set; }
+
+ [JsonIgnore]
+ public DateTime ReceivedTsDateTime {
+ get => DateTimeOffset.FromUnixTimeMilliseconds(ReceivedTs).DateTime;
+ set => ReceivedTs = new DateTimeOffset(value).ToUnixTimeMilliseconds();
+ }
+ }
+
+ public class SynapseAdminEventReportListResultReportWithDetails : SynapseAdminEventReportListResultReport {
+ [JsonPropertyName("event_json")]
+ public SynapseEventJson EventJson { get; set; }
+
+ public class SynapseEventJson {
+ [JsonPropertyName("auth_events")]
+ public List<string> AuthEvents { get; set; }
+
+ [JsonPropertyName("content")]
+ public JsonObject? RawContent { get; set; }
+
+ [JsonPropertyName("depth")]
+ public int Depth { get; set; }
+
+ [JsonPropertyName("hashes")]
+ public Dictionary<string, string> Hashes { get; set; }
+
+ [JsonPropertyName("origin")]
+ public string Origin { get; set; }
+
+ [JsonPropertyName("origin_server_ts")]
+ public long OriginServerTs { get; set; }
+
+ [JsonPropertyName("prev_events")]
+ public List<string> PrevEvents { get; set; }
+
+ [JsonPropertyName("prev_state")]
+ public List<object> PrevState { get; set; }
+
+ [JsonPropertyName("room_id")]
+ public string RoomId { get; set; }
+
+ [JsonPropertyName("sender")]
+ public string Sender { get; set; }
+
+ [JsonPropertyName("signatures")]
+ public Dictionary<string, Dictionary<string, string>> Signatures { get; set; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; }
+
+ [JsonPropertyName("unsigned")]
+ public JsonObject? Unsigned { get; set; }
+
+ // Extra... copied from StateEventResponse
+
+ [JsonIgnore]
+ public Type MappedType => StateEvent.GetStateEventType(Type);
+
+ [JsonIgnore]
+ public bool IsLegacyType => MappedType.GetCustomAttributes<MatrixEventAttribute>().FirstOrDefault(x => x.EventName == Type)?.Legacy ?? false;
+
+ [JsonIgnore]
+ public string FriendlyTypeName => MappedType.GetFriendlyNameOrNull() ?? Type;
+
+ [JsonIgnore]
+ public string FriendlyTypeNamePlural => MappedType.GetFriendlyNamePluralOrNull() ?? Type;
+
+ private static readonly JsonSerializerOptions TypedContentSerializerOptions = new() {
+ Converters = {
+ new JsonFloatStringConverter(),
+ new JsonDoubleStringConverter(),
+ new JsonDecimalStringConverter()
+ }
+ };
+
+ [JsonIgnore]
+ [SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")]
+ public EventContent? TypedContent {
+ get {
+ ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies();
+ // if (Type == "m.receipt") {
+ // return null;
+ // }
+ try {
+ var mappedType = StateEvent.GetStateEventType(Type);
+ if (mappedType == typeof(UnknownEventContent))
+ Console.WriteLine($"Warning: unknown event type '{Type}'");
+ var deserialisedContent = (EventContent)RawContent.Deserialize(mappedType, TypedContentSerializerOptions)!;
+ return deserialisedContent;
+ }
+ catch (JsonException e) {
+ Console.WriteLine(e);
+ Console.WriteLine("Content:\n" + (RawContent?.ToJson() ?? "null"));
+ }
+
+ return null;
+ }
+ set {
+ if (value is null)
+ RawContent?.Clear();
+ else
+ RawContent = JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(value, value.GetType(),
+ new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }));
+ }
+ }
+
+ //debug
+ [JsonIgnore]
+ public string InternalSelfTypeName {
+ get {
+ var res = GetType().Name switch {
+ "StateEvent`1" => "StateEvent",
+ _ => GetType().Name
+ };
+ return res;
+ }
+ }
+
+ [JsonIgnore]
+ public string InternalContentTypeName => TypedContent?.GetType().Name ?? "null";
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs
new file mode 100644
index 0000000..fa92ef9
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRegistrationTokenListResult {
+ [JsonPropertyName("registration_tokens")]
+ public List<SynapseAdminRegistrationTokenListResultToken> RegistrationTokens { get; set; } = new();
+
+ public class SynapseAdminRegistrationTokenListResultToken {
+ [JsonPropertyName("token")]
+ public string Token { get; set; }
+
+ [JsonPropertyName("uses_allowed")]
+ public int? UsesAllowed { get; set; }
+
+ [JsonPropertyName("pending")]
+ public int Pending { get; set; }
+
+ [JsonPropertyName("completed")]
+ public int Completed { get; set; }
+
+ [JsonPropertyName("expiry_time")]
+ public long? ExpiryTime { get; set; }
+
+ [JsonIgnore]
+ public DateTime? ExpiryTimeDateTime {
+ get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime : null;
+ set => ExpiryTime = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListingResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
index 7ab96ac..d84c89b 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListingResult.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
@@ -1,8 +1,8 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.Responses.Admin;
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
-public class AdminRoomListingResult {
+public class SynapseAdminRoomListResult {
[JsonPropertyName("offset")]
public int Offset { get; set; }
@@ -16,9 +16,9 @@ public class AdminRoomListingResult {
public int? PrevBatch { get; set; }
[JsonPropertyName("rooms")]
- public List<AdminRoomListingResultRoom> Rooms { get; set; } = new();
+ public List<SynapseAdminRoomListResultRoom> Rooms { get; set; } = new();
- public class AdminRoomListingResultRoom {
+ public class SynapseAdminRoomListResultRoom {
[JsonPropertyName("room_id")]
public required string RoomId { get; set; }
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs
new file mode 100644
index 0000000..97e85ad
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRoomMediaListResult {
+ [JsonPropertyName("local")]
+ public List<string> Local { get; set; } = new();
+
+ [JsonPropertyName("remote")]
+ public List<string> Remote { get; set; } = new();
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs
new file mode 100644
index 0000000..cb2ec08
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRoomMemberListResult {
+ [JsonPropertyName("members")]
+ public List<string> Members { get; set; }
+
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomStateResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomStateResult.cs
new file mode 100644
index 0000000..ae36d4e
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomStateResult.cs
@@ -0,0 +1,8 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRoomStateResult {
+ [JsonPropertyName("state")]
+ public required List<StateEventResponse> Events { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs
new file mode 100644
index 0000000..3f5f865
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminUserRedactIdResponse {
+ [JsonPropertyName("redact_id")]
+ public string RedactionId { get; set; }
+}
+
+public class SynapseAdminRedactStatusResponse {
+ /// <summary>
+ /// One of "scheduled", "active", "completed", "failed"
+ /// </summary>
+ [JsonPropertyName("status")]
+ public string Status { get; set; }
+
+ /// <summary>
+ /// Key: Event ID, Value: Error message
+ /// </summary>
+ [JsonPropertyName("failed_redactions")]
+ public Dictionary<string, string> FailedRedactions { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs
new file mode 100644
index 0000000..36a5596
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs
@@ -0,0 +1,250 @@
+using System.Buffers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseNextTokenTotalCollectionResult {
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+
+ [JsonPropertyName("next_token")]
+ public string? NextToken { get; set; }
+}
+
+// [JsonConverter(typeof(SynapseCollectionJsonConverter<>))]
+public class SynapseCollectionResult<T>(string chunkKey = "chunk", string prevTokenKey = "prev_token", string nextTokenKey = "next_token", string totalKey = "total") {
+ public int? Total { get; set; }
+ public string? PrevToken { get; set; }
+ public string? NextToken { get; set; }
+ public List<T> Chunk { get; set; } = [];
+
+ // TODO: figure out how to provide an IAsyncEnumerable<T> for this
+ // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/use-utf8jsonreader#read-from-a-stream-using-utf8jsonreader
+
+ // public async IAsyncEnumerable<T> FromJsonAsync(Stream stream) {
+ //
+ // }
+
+ public SynapseCollectionResult<T> FromJson(Stream stream, Action<T> action) {
+ byte[] buffer = new byte[4096];
+ _ = stream.Read(buffer);
+ var reader = new Utf8JsonReader(buffer, isFinalBlock: false, state: default);
+
+ try {
+ FromJsonInternal(stream, ref buffer, ref reader, action);
+ }
+ catch (JsonException e) {
+ Console.WriteLine($"Caught a JsonException: {e}");
+ int hexdumpWidth = 64;
+ Console.WriteLine($"Check hexdump line {reader.BytesConsumed / hexdumpWidth} index {reader.BytesConsumed % hexdumpWidth}");
+ buffer.HexDump(64);
+ }
+ finally { }
+
+ return this;
+ }
+
+ private void FromJsonInternal(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader, Action<T> action) {
+ while (!reader.IsFinalBlock) {
+ while (!reader.Read()) {
+ GetMoreBytesFromStream(stream, ref buffer, ref reader);
+ }
+
+ if (reader.TokenType == JsonTokenType.PropertyName) {
+ var propName = reader.GetString();
+ Console.WriteLine($"SynapseCollectionResult: encountered property name: {propName}");
+
+ while (!reader.Read()) {
+ GetMoreBytesFromStream(stream, ref buffer, ref reader);
+ }
+
+ Console.WriteLine($"{reader.BytesConsumed}/{stream.Position} {reader.TokenType}");
+
+ if (propName == totalKey && reader.TokenType == JsonTokenType.Number) {
+ Total = reader.GetInt32();
+ }
+ else if (propName == prevTokenKey && reader.TokenType == JsonTokenType.String) {
+ PrevToken = reader.GetString();
+ }
+ else if (propName == nextTokenKey && reader.TokenType == JsonTokenType.String) {
+ NextToken = reader.GetString();
+ }
+ else if (propName == chunkKey) {
+ if (reader.TokenType == JsonTokenType.StartArray) {
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) {
+ // if (reader.TokenType == JsonTokenType.EndArray) {
+ // break;
+ // }
+ // Console.WriteLine($"Encountered token in chunk: {reader.TokenType}");
+ // var _buf = reader.ValueSequence.ToArray();
+ // try {
+ // var item = JsonSerializer.Deserialize<T>(_buf);
+ // action(item);
+ // Chunk.Add(item);
+ // }
+ // catch(JsonException e) {
+ // Console.WriteLine($"Caught a JsonException: {e}");
+ // int hexdumpWidth = 64;
+ //
+ // // Console.WriteLine($"Check hexdump line {reader.BytesConsumed / hexdumpWidth} index {reader.BytesConsumed % hexdumpWidth}");
+ // Console.WriteLine($"Buffer length: {_buf.Length}");
+ // _buf.HexDump(64);
+ // throw;
+ // }
+ var item = ReadItem(stream, ref buffer, ref reader);
+ action(item);
+ Chunk.Add(item);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private T ReadItem(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+ while (!reader.Read()) {
+ GetMoreBytesFromStream(stream, ref buffer, ref reader);
+ }
+
+ // handle nullable types
+ if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) {
+ if (reader.TokenType == JsonTokenType.Null) {
+ return default(T);
+ }
+ }
+
+ // if(typeof(T) == typeof(string)) {
+ // return (T)(object)reader.GetString();
+ // }
+ // else if(typeof(T) == typeof(int)) {
+ // return (T)(object)reader.GetInt32();
+ // }
+ // else {
+ // var _buf = reader.ValueSequence.ToArray();
+ // return JsonSerializer.Deserialize<T>(_buf);
+ // }
+
+ // default branch uses "object?" cast to avoid compiler error
+ // add more branches here as nessesary
+ // reader.Read();
+ var call = typeof(T) switch {
+ Type t when t == typeof(string) => reader.GetString(),
+ _ => ReadObject<T>(stream, ref buffer, ref reader)
+ };
+
+ object ReadObject<T>(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+ if (reader.TokenType != JsonTokenType.PropertyName) {
+ throw new JsonException();
+ }
+
+ List<byte> objBuffer = [(byte)'{', ..reader.ValueSequence.ToArray()];
+ var currentDepth = reader.CurrentDepth;
+ while (reader.CurrentDepth >= currentDepth) {
+ while (!reader.Read()) {
+ GetMoreBytesFromStream(stream, ref buffer, ref reader);
+ }
+
+ if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == currentDepth) {
+ break;
+ }
+
+ objBuffer.AddRange(reader.ValueSpan);
+ }
+
+ return JsonSerializer.Deserialize<T>(objBuffer.ToArray());
+ }
+
+ return (T)call;
+
+ // return JsonSerializer.Deserialize<T>(ref reader);
+ }
+
+ private static void GetMoreBytesFromStream(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+ int bytesRead;
+ if (reader.BytesConsumed < buffer.Length) {
+ ReadOnlySpan<byte> leftover = buffer.AsSpan((int)reader.BytesConsumed);
+
+ if (leftover.Length == buffer.Length) {
+ Array.Resize(ref buffer, buffer.Length * 2);
+ Console.WriteLine($"Increased buffer size to {buffer.Length}");
+ }
+
+ leftover.CopyTo(buffer);
+ bytesRead = stream.Read(buffer.AsSpan(leftover.Length));
+ }
+ else {
+ bytesRead = stream.Read(buffer);
+ }
+
+ // Console.WriteLine($"String in buffer is: {Encoding.UTF8.GetString(buffer)}");
+ reader = new Utf8JsonReader(buffer, isFinalBlock: bytesRead == 0, reader.CurrentState);
+ }
+}
+
+public partial class SynapseCollectionJsonConverter<T> : JsonConverter<SynapseCollectionResult<T>> {
+ public override SynapseCollectionResult<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
+ if (reader.TokenType != JsonTokenType.StartObject) {
+ throw new JsonException();
+ }
+
+ var result = new SynapseCollectionResult<T>();
+ while (reader.Read()) {
+ if (reader.TokenType == JsonTokenType.EndObject) {
+ break;
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName) {
+ throw new JsonException();
+ }
+
+ var propName = reader.GetString();
+ reader.Read();
+ if (propName == "total") {
+ result.Total = reader.GetInt32();
+ }
+ else if (propName == "prev_token") {
+ result.PrevToken = reader.GetString();
+ }
+ else if (propName == "next_token") {
+ result.NextToken = reader.GetString();
+ }
+ else if (propName == "chunk") {
+ if (reader.TokenType != JsonTokenType.StartArray) {
+ throw new JsonException();
+ }
+
+ while (reader.Read()) {
+ if (reader.TokenType == JsonTokenType.EndArray) {
+ break;
+ }
+
+ var item = JsonSerializer.Deserialize<T>(ref reader, options);
+ result.Chunk.Add(item);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, SynapseCollectionResult<T> value, JsonSerializerOptions options) {
+ writer.WriteStartObject();
+ if (value.Total is not null)
+ writer.WriteNumber("total", value.Total ?? 0);
+ if (value.PrevToken is not null)
+ writer.WriteString("prev_token", value.PrevToken);
+ if (value.NextToken is not null)
+ writer.WriteString("next_token", value.NextToken);
+
+ writer.WriteStartArray("chunk");
+ foreach (var item in value.Chunk) {
+ JsonSerializer.Serialize(writer, item, options);
+ }
+
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs
new file mode 100644
index 0000000..5530cc3
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs
@@ -0,0 +1,40 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminUserMediaResult {
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+
+ [JsonPropertyName("next_token")]
+ public string? NextToken { get; set; }
+
+ [JsonPropertyName("media")]
+ public List<MediaInfo> Media { get; set; } = new();
+
+ public class MediaInfo {
+ [JsonPropertyName("created_ts")]
+ public long CreatedTimestamp { get; set; }
+
+ [JsonPropertyName("last_access_ts")]
+ public long? LastAccessTimestamp { get; set; }
+
+ [JsonPropertyName("media_id")]
+ public string MediaId { get; set; }
+
+ [JsonPropertyName("media_length")]
+ public int MediaLength { get; set; }
+
+ [JsonPropertyName("media_type")]
+ public string MediaType { get; set; }
+
+ [JsonPropertyName("quarantined_by")]
+ public string? QuarantinedBy { get; set; }
+
+ [JsonPropertyName("safe_from_quarantine")]
+ public bool SafeFromQuarantine { get; set; }
+
+ [JsonPropertyName("upload_name")]
+ public string UploadName { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs
new file mode 100644
index 0000000..3132906
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs
@@ -0,0 +1,71 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminUserListResult {
+ [JsonPropertyName("offset")]
+ public int Offset { get; set; }
+
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+
+ [JsonPropertyName("next_token")]
+ public string? NextToken { get; set; }
+
+ [JsonPropertyName("users")]
+ public List<SynapseAdminUserListResultUser> Users { get; set; } = new();
+
+ public class SynapseAdminUserListResultUser {
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ [JsonPropertyName("is_guest")]
+ public bool? IsGuest { get; set; }
+
+ [JsonPropertyName("admin")]
+ public bool? Admin { get; set; }
+
+ [JsonPropertyName("user_type")]
+ public string? UserType { get; set; }
+
+ [JsonPropertyName("deactivated")]
+ public bool Deactivated { get; set; }
+
+ [JsonPropertyName("erased")]
+ public bool Erased { get; set; }
+
+ [JsonPropertyName("shadow_banned")]
+ public bool ShadowBanned { get; set; }
+
+ [JsonPropertyName("displayname")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("avatar_url")]
+ public string? AvatarUrl { get; set; }
+
+ [JsonPropertyName("creation_ts")]
+ public long CreationTs { get; set; }
+
+ [JsonPropertyName("last_seen_ts")]
+ public long? LastSeenTs { get; set; }
+
+ [JsonPropertyName("locked")]
+ public bool Locked { get; set; }
+
+ // Requires enabling MSC3866
+ [JsonPropertyName("approved")]
+ public bool? Approved { get; set; }
+
+ [JsonIgnore]
+ public DateTime CreationTsDateTime {
+ get => DateTimeOffset.FromUnixTimeMilliseconds(CreationTs).DateTime;
+ set => CreationTs = new DateTimeOffset(value).ToUnixTimeMilliseconds();
+ }
+
+ [JsonIgnore]
+ public DateTime? LastSeenTsDateTime {
+ get => LastSeenTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(LastSeenTs.Value).DateTime : null;
+ set => LastSeenTs = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
index ac94a7a..cee3d8d 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
@@ -1,90 +1,163 @@
+// #define LOG_SKIP
+
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
using ArcaneLibs.Extensions;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+using LibMatrix.Responses;
using LibMatrix.Filters;
-using LibMatrix.Responses.Admin;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+using LibMatrix.Responses;
namespace LibMatrix.Homeservers.ImplementationDetails.Synapse;
public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedHomeserver) {
- public async IAsyncEnumerable<AdminRoomListingResult.AdminRoomListingResultRoom> SearchRoomsAsync(int limit = int.MaxValue, string orderBy = "name", string dir = "f",
- string? searchTerm = null, LocalRoomQueryFilter? localFilter = null) {
- AdminRoomListingResult? res = null;
+ private SynapseAdminUserCleanupExecutor UserCleanupExecutor { get; } = new(authenticatedHomeserver);
+ // https://github.com/element-hq/synapse/tree/develop/docs/admin_api
+ // https://github.com/element-hq/synapse/tree/develop/docs/usage/administration/admin_api
+
+#region Rooms
+
+ public async IAsyncEnumerable<SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom> SearchRoomsAsync(int limit = int.MaxValue, int chunkLimit = 250,
+ string orderBy = "name", string dir = "f", string? searchTerm = null, SynapseAdminLocalRoomQueryFilter? localFilter = null) {
+ SynapseAdminRoomListResult? res = null;
var i = 0;
int? totalRooms = null;
do {
- var url = $"/_synapse/admin/v1/rooms?limit={Math.Min(limit, 250)}&dir={dir}&order_by={orderBy}";
+ var url = $"/_synapse/admin/v1/rooms?limit={Math.Min(limit, chunkLimit)}&dir={dir}&order_by={orderBy}";
if (!string.IsNullOrEmpty(searchTerm)) url += $"&search_term={searchTerm}";
if (res?.NextBatch is not null) url += $"&from={res.NextBatch}";
Console.WriteLine($"--- ADMIN Querying Room List with URL: {url} - Already have {i} items... ---");
- res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<AdminRoomListingResult>(url);
+ res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomListResult>(url);
totalRooms ??= res.TotalRooms;
- Console.WriteLine(res.ToJson(false));
+ // Console.WriteLine(res.ToJson(false));
foreach (var room in res.Rooms) {
if (localFilter is not null) {
- if (!room.RoomId.Contains(localFilter.RoomIdContains)) {
+ if (!string.IsNullOrWhiteSpace(localFilter.RoomIdContains) && !room.RoomId.Contains(localFilter.RoomIdContains, StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule roomid.");
+#endif
continue;
}
- if (!room.Name?.Contains(localFilter.NameContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.NameContains) && room.Name?.Contains(localFilter.NameContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule roomname.");
+#endif
continue;
}
- if (!room.CanonicalAlias?.Contains(localFilter.CanonicalAliasContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.CanonicalAliasContains) &&
+ room.CanonicalAlias?.Contains(localFilter.CanonicalAliasContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule alias.");
+#endif
continue;
}
- if (!room.Version.Contains(localFilter.VersionContains)) {
+ if (!string.IsNullOrWhiteSpace(localFilter.VersionContains) && !room.Version.Contains(localFilter.VersionContains, StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule version.");
+#endif
continue;
}
- if (!room.Creator.Contains(localFilter.CreatorContains)) {
+ if (!string.IsNullOrWhiteSpace(localFilter.CreatorContains) && !room.Creator.Contains(localFilter.CreatorContains, StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule creator.");
+#endif
continue;
}
- if (!room.Encryption?.Contains(localFilter.EncryptionContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.EncryptionContains) &&
+ room.Encryption?.Contains(localFilter.EncryptionContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule encryption.");
+#endif
continue;
}
- if (!room.JoinRules?.Contains(localFilter.JoinRulesContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.JoinRulesContains) &&
+ room.JoinRules?.Contains(localFilter.JoinRulesContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joinrules.");
+#endif
continue;
}
- if (!room.GuestAccess?.Contains(localFilter.GuestAccessContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.GuestAccessContains) &&
+ room.GuestAccess?.Contains(localFilter.GuestAccessContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule guestaccess.");
+#endif
continue;
}
- if (!room.HistoryVisibility?.Contains(localFilter.HistoryVisibilityContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.HistoryVisibilityContains) &&
+ room.HistoryVisibility?.Contains(localFilter.HistoryVisibilityContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule history visibility.");
+#endif
continue;
}
if (localFilter.CheckFederation && room.Federatable != localFilter.Federatable) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule federation.");
+#endif
continue;
}
if (localFilter.CheckPublic && room.Public != localFilter.Public) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule public.");
+#endif
+ continue;
+ }
+
+ if (room.StateEvents < localFilter.StateEventsGreaterThan || room.StateEvents > localFilter.StateEventsLessThan) {
+ totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined local members.");
+#endif
continue;
}
if (room.JoinedMembers < localFilter.JoinedMembersGreaterThan || room.JoinedMembers > localFilter.JoinedMembersLessThan) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined members: {localFilter.JoinedMembersGreaterThan} < {room.JoinedLocalMembers} < {localFilter.JoinedMembersLessThan}.");
+#endif
continue;
}
if (room.JoinedLocalMembers < localFilter.JoinedLocalMembersGreaterThan || room.JoinedLocalMembers > localFilter.JoinedLocalMembersLessThan) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined local members: {localFilter.JoinedLocalMembersGreaterThan} < {room.JoinedLocalMembers} < {localFilter.JoinedLocalMembersLessThan}.");
+#endif
continue;
}
}
@@ -104,4 +177,386 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
}
} while (i < Math.Min(limit, totalRooms ?? limit));
}
+
+#endregion
+
+#region Users
+
+ public async IAsyncEnumerable<SynapseAdminUserListResult.SynapseAdminUserListResultUser> SearchUsersAsync(int limit = int.MaxValue, int chunkLimit = 250,
+ string orderBy = "name", string dir = "f",
+ SynapseAdminLocalUserQueryFilter? localFilter = null) {
+ // TODO: implement filters
+ string? from = null;
+ while (limit > 0) {
+ var url = new Uri("/_synapse/admin/v3/users", UriKind.Relative);
+ url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+ if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+ if (!string.IsNullOrWhiteSpace(orderBy)) url = url.AddQuery("order_by", orderBy);
+ if (!string.IsNullOrWhiteSpace(dir)) url = url.AddQuery("dir", dir);
+
+ Console.WriteLine($"--- ADMIN Querying User List with URL: {url} ---");
+ // TODO: implement URI methods in http client
+ var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminUserListResult>(url.ToString());
+ foreach (var user in res.Users) {
+ limit--;
+ yield return user;
+ }
+
+ if (string.IsNullOrWhiteSpace(res.NextToken)) break;
+ from = res.NextToken;
+ }
+ }
+
+ public async Task<LoginResponse> LoginUserAsync(string userId, TimeSpan expireAfter) {
+ var url = new Uri($"/_synapse/admin/v1/users/{userId.UrlEncode()}/login", UriKind.Relative);
+ url.AddQuery("valid_until_ms", DateTimeOffset.UtcNow.Add(expireAfter).ToUnixTimeMilliseconds().ToString());
+ var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new());
+ var loginResp = await resp.Content.ReadFromJsonAsync<LoginResponse>();
+ loginResp.UserId = userId; // Synapse only returns the access token
+ return loginResp;
+ }
+
+ public async Task<AuthenticatedHomeserverSynapse> GetHomeserverForUserAsync(string userId, TimeSpan expireAfter) {
+ var loginResp = await LoginUserAsync(userId, expireAfter);
+ var homeserver = new AuthenticatedHomeserverSynapse(
+ authenticatedHomeserver.ServerName, authenticatedHomeserver.WellKnownUris, authenticatedHomeserver.Proxy, loginResp.AccessToken
+ );
+ await homeserver.Initialise();
+ return homeserver;
+ }
+
+#endregion
+
+#region Reports
+
+ public async IAsyncEnumerable<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReport> GetEventReportsAsync(int limit = int.MaxValue, int chunkLimit = 250,
+ string dir = "f", SynapseAdminLocalEventReportQueryFilter? filter = null) {
+ // TODO: implement filters
+ string? from = null;
+ while (limit > 0) {
+ var url = new Uri("/_synapse/admin/v1/event_reports", UriKind.Relative);
+ url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+ if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+ Console.WriteLine($"--- ADMIN Querying Reports with URL: {url} ---");
+ var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminEventReportListResult>(url.ToString());
+ foreach (var report in res.Reports) {
+ limit--;
+ yield return report;
+ }
+
+ if (string.IsNullOrWhiteSpace(res.NextToken)) break;
+ from = res.NextToken;
+ }
+ }
+
+ public async Task<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails> GetEventReportDetailsAsync(string reportId) {
+ var url = new Uri($"/_synapse/admin/v1/event_reports/{reportId.UrlEncode()}", UriKind.Relative);
+ return await authenticatedHomeserver.ClientHttpClient
+ .GetFromJsonAsync<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails>(url.ToString());
+ }
+
+ // Utility function to get details straight away
+ public async IAsyncEnumerable<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails> GetEventReportsWithDetailsAsync(int limit = int.MaxValue,
+ int chunkLimit = 250, string dir = "f", SynapseAdminLocalEventReportQueryFilter? filter = null) {
+ Queue<Task<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails>> tasks = [];
+ await foreach (var report in GetEventReportsAsync(limit, chunkLimit, dir, filter)) {
+ tasks.Enqueue(GetEventReportDetailsAsync(report.Id));
+ while (tasks.Peek().IsCompleted) yield return await tasks.Dequeue(); // early return if possible
+ }
+
+ while (tasks.Count > 0) yield return await tasks.Dequeue();
+ }
+
+ public async Task DeleteEventReportAsync(string reportId) {
+ var url = new Uri($"/_synapse/admin/v1/event_reports/{reportId.UrlEncode()}", UriKind.Relative);
+ await authenticatedHomeserver.ClientHttpClient.DeleteAsync(url.ToString());
+ }
+
+#endregion
+
+#region Background Updates
+
+ public async Task<bool> GetBackgroundUpdatesEnabledAsync() {
+ var url = new Uri("/_synapse/admin/v1/background_updates/enabled", UriKind.Relative);
+ // The return type is technically wrong, but includes the field we want.
+ var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(url.ToString());
+ return resp.Enabled;
+ }
+
+ public async Task<bool> SetBackgroundUpdatesEnabledAsync(bool enabled) {
+ var url = new Uri("/_synapse/admin/v1/background_updates/enabled", UriKind.Relative);
+ // The used types are technically wrong, but include the field we want.
+ var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new JsonObject {
+ ["enabled"] = enabled
+ });
+ var json = await resp.Content.ReadFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>();
+ return json!.Enabled;
+ }
+
+ public async Task<SynapseAdminBackgroundUpdateStatusResponse> GetBackgroundUpdatesStatusAsync() {
+ var url = new Uri("/_synapse/admin/v1/background_updates/status", UriKind.Relative);
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(url.ToString());
+ }
+
+ /// <summary>
+ /// Run a background job
+ /// </summary>
+ /// <param name="jobName">One of "populate_stats_process_rooms" or "regenerate_directory"</param>
+ public async Task RunBackgroundJobsAsync(string jobName) {
+ var url = new Uri("/_synapse/admin/v1/background_updates/run", UriKind.Relative);
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync(url.ToString(), new JsonObject() {
+ ["job_name"] = jobName
+ });
+ }
+
+#endregion
+
+#region Federation
+
+ public async IAsyncEnumerable<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination> GetFederationDestinationsAsync(int limit = int.MaxValue,
+ int chunkLimit = 250) {
+ string? from = null;
+ while (limit > 0) {
+ var url = new Uri("/_synapse/admin/v1/federation/destinations", UriKind.Relative);
+ url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+ if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+ Console.WriteLine($"--- ADMIN Querying Federation Destinations with URL: {url} ---");
+ var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationListResult>(url.ToString());
+ foreach (var dest in res.Destinations) {
+ limit--;
+ yield return dest;
+ }
+ }
+ }
+
+ public async Task<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination> GetFederationDestinationDetailsAsync(string destination) {
+ var url = new Uri($"/_synapse/admin/v1/federation/destinations/{destination}", UriKind.Relative);
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination>(url.ToString());
+ }
+
+ public async IAsyncEnumerable<SynapseAdminDestinationRoomListResult.SynapseAdminDestinationRoomListResultRoom> GetFederationDestinationRoomsAsync(string destination,
+ int limit = int.MaxValue, int chunkLimit = 250) {
+ string? from = null;
+ while (limit > 0) {
+ var url = new Uri($"/_synapse/admin/v1/federation/destinations/{destination}/rooms", UriKind.Relative);
+ url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+ if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+ Console.WriteLine($"--- ADMIN Querying Federation Destination Rooms with URL: {url} ---");
+ var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationRoomListResult>(url.ToString());
+ foreach (var room in res.Rooms) {
+ limit--;
+ yield return room;
+ }
+ }
+ }
+
+ public async Task ResetFederationConnectionTimeoutAsync(string destination) {
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/federation/destinations/{destination}/reset_connection", new JsonObject());
+ }
+
+#endregion
+
+#region Registration Tokens
+
+ // does not support pagination
+ public async Task<List<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>> GetRegistrationTokensAsync() {
+ var url = new Uri("/_synapse/admin/v1/registration_tokens", UriKind.Relative);
+ var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRegistrationTokenListResult>(url.ToString());
+ return resp.RegistrationTokens;
+ }
+
+ public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> GetRegistrationTokenAsync(string token) {
+ var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+ var resp =
+ await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>(url.ToString());
+ return resp;
+ }
+
+ public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> CreateRegistrationTokenAsync(
+ SynapseAdminRegistrationTokenCreateRequest request) {
+ var url = new Uri("/_synapse/admin/v1/", UriKind.Relative);
+ var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync(url.ToString(), request);
+ var token = await resp.Content.ReadFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>();
+ return token!;
+ }
+
+ public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> UpdateRegistrationTokenAsync(string token,
+ SynapseAdminRegistrationTokenUpdateRequest request) {
+ var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+ var resp = await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync(url.ToString(), request);
+ return await resp.Content.ReadFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>();
+ }
+
+ public async Task DeleteRegistrationTokenAsync(string token) {
+ var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+ await authenticatedHomeserver.ClientHttpClient.DeleteAsync(url.ToString());
+ }
+
+#endregion
+
+#region Account Validity
+
+ // Does anyone even use this?
+ // README: https://github.com/matrix-org/synapse/issues/15271
+ // -> Don't implement unless requested, if not for this feature almost never being used.
+
+#endregion
+
+#region Experimental Features
+
+ public async Task<Dictionary<string, bool>> GetExperimentalFeaturesAsync(string userId) {
+ var url = new Uri($"/_synapse/admin/v1/experimental_features/{userId.UrlEncode()}", UriKind.Relative);
+ var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<JsonObject>(url.ToString());
+ return resp["features"]!.GetValue<Dictionary<string, bool>>();
+ }
+
+ public async Task SetExperimentalFeaturesAsync(string userId, Dictionary<string, bool> features) {
+ var url = new Uri($"/_synapse/admin/v1/experimental_features/{userId.UrlEncode()}", UriKind.Relative);
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new JsonObject {
+ ["features"] = JsonSerializer.Deserialize<JsonObject>(features.ToJson())
+ });
+ }
+
+#endregion
+
+#region Media
+
+ public async Task<SynapseAdminRoomMediaListResult> GetRoomMediaAsync(string roomId) {
+ var url = new Uri($"/_synapse/admin/v1/room/{roomId.UrlEncode()}/media", UriKind.Relative);
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomMediaListResult>(url.ToString());
+ }
+
+ // This is in the user admin API section
+ // public async IAsyncEnumerable<SynapseAdminRoomMediaListResult>
+
+#endregion
+
+ public async Task<SynapseAdminUserRedactIdResponse?> DeleteAllMessages(string mxid, List<string>? rooms = null, string? reason = null, int? limit = 100000,
+ bool waitForCompletion = true) {
+ rooms ??= [];
+
+ Dictionary<string, object> payload = new();
+ if (rooms.Count > 0) payload["rooms"] = rooms;
+ if (!string.IsNullOrEmpty(reason)) payload["reason"] = reason;
+ if (limit.HasValue) payload["limit"] = limit.Value;
+
+ var redactIdResp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/user/{mxid}/redact", payload);
+ var redactId = await redactIdResp.Content.ReadFromJsonAsync<SynapseAdminUserRedactIdResponse>();
+
+ if (waitForCompletion) {
+ while (true) {
+ var status = await GetRedactStatus(redactId!.RedactionId);
+ if (status?.Status != "pending") break;
+ await Task.Delay(1000);
+ }
+ }
+
+ return redactId;
+ }
+
+ public async Task<SynapseAdminRedactStatusResponse?> GetRedactStatus(string redactId) {
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRedactStatusResponse>(
+ $"/_synapse/admin/v1/user/redact_status/{redactId}");
+ }
+
+ public async Task DeactivateUserAsync(string mxid, bool erase = false, bool eraseMessages = false, bool extraCleanup = false) {
+ if (eraseMessages) {
+ await DeleteAllMessages(mxid);
+ }
+
+ if (extraCleanup) {
+ await UserCleanupExecutor.CleanupUser(mxid);
+ }
+
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/deactivate", new { erase });
+ }
+
+ public async Task ResetPasswordAsync(string mxid, string newPassword, bool logoutDevices = false) {
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/reset_password/{mxid}",
+ new { new_password = newPassword, logout_devices = logoutDevices });
+ }
+
+ public async Task<SynapseAdminUserMediaResult> GetUserMediaAsync(string mxid, int? limit = 100, string? from = null, string? orderBy = null, string? dir = null) {
+ var url = $"/_synapse/admin/v1/users/{mxid}/media";
+ if (limit.HasValue) url += $"?limit={limit}";
+ if (!string.IsNullOrEmpty(from)) url += $"&from={from}";
+ if (!string.IsNullOrEmpty(orderBy)) url += $"&order_by={orderBy}";
+ if (!string.IsNullOrEmpty(dir)) url += $"&dir={dir}";
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminUserMediaResult>(url);
+ }
+
+ public async IAsyncEnumerable<SynapseAdminUserMediaResult.MediaInfo> GetUserMediaEnumerableAsync(string mxid, int chunkSize = 100, string? orderBy = null, string? dir = null) {
+ SynapseAdminUserMediaResult? res = null;
+ do {
+ res = await GetUserMediaAsync(mxid, chunkSize, res?.NextToken, orderBy, dir);
+ foreach (var media in res.Media) {
+ yield return media;
+ }
+ } while (!string.IsNullOrEmpty(res.NextToken));
+ }
+
+ public async Task BlockRoom(string roomId, bool block = true) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/rooms/{roomId.UrlEncode()}/block", new {
+ block
+ });
+ }
+
+ public async Task<SynapseAdminRoomDeleteResponse> DeleteRoom(string roomId, SynapseAdminRoomDeleteRequest request, bool waitForCompletion = true) {
+ var resp = await authenticatedHomeserver.ClientHttpClient.DeleteAsJsonAsync($"/_synapse/admin/v2/rooms/{roomId.UrlEncode()}", request);
+ var deleteResp = await resp.Content.ReadFromJsonAsync<SynapseAdminRoomDeleteResponse>();
+
+ if (waitForCompletion) {
+ while (true) {
+ var status = await GetRoomDeleteStatus(deleteResp!.DeleteId);
+ if (status?.Status != "pending") break;
+ await Task.Delay(1000);
+ }
+ }
+
+ return deleteResp!;
+ }
+
+ public async Task<SynapseAdminRoomDeleteStatus> GetRoomDeleteStatusByRoomId(string roomId) {
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomDeleteStatus>(
+ $"/_synapse/admin/v2/rooms/{roomId.UrlEncode()}/delete_status");
+ }
+
+ public async Task<SynapseAdminRoomDeleteStatus> GetRoomDeleteStatus(string deleteId) {
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomDeleteStatus>(
+ $"/_synapse/admin/v2/rooms/delete_status/{deleteId}");
+ }
+
+ public async Task<SynapseAdminRoomMemberListResult> GetRoomMembersAsync(string roomId) {
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomMemberListResult>($"/_synapse/admin/v1/rooms/{roomId.UrlEncode()}/members");
+ }
+
+ public async Task<SynapseAdminRoomStateResult> GetRoomStateAsync(string roomId, string? type = null) {
+ return string.IsNullOrWhiteSpace(type)
+ ? await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomStateResult>($"/_synapse/admin/v1/rooms/{roomId.UrlEncode()}/state")
+ : await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomStateResult>($"/_synapse/admin/v1/rooms/{roomId.UrlEncode()}/state?type={type}");
+ }
+
+ public async Task QuarantineMediaByRoomId(string roomId) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/room/{roomId.UrlEncode()}/media/quarantine", new { });
+ }
+
+ public async Task QuarantineMediaByUserId(string mxid) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/user/{mxid}/media/quarantine", new { });
+ }
+
+ public async Task QuarantineMediaById(string serverName, string mediaId) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/media/quarantine/{serverName}/{mediaId}", new { });
+ }
+
+ public async Task QuarantineMediaById(MxcUri mxcUri) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/media/quarantine/{mxcUri.ServerName}/{mxcUri.MediaId}", new { });
+ }
+
+ public async Task DeleteMediaById(string serverName, string mediaId) {
+ await authenticatedHomeserver.ClientHttpClient.DeleteAsync($"/_synapse/admin/v1/media/{serverName}/{mediaId}");
+ }
+
+ public async Task DeleteMediaById(MxcUri mxcUri) {
+ await authenticatedHomeserver.ClientHttpClient.DeleteAsync($"/_synapse/admin/v1/media/{mxcUri.ServerName}/{mxcUri.MediaId}");
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs
new file mode 100644
index 0000000..6edf40c
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs
@@ -0,0 +1,27 @@
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse;
+
+public class SynapseAdminUserCleanupExecutor(AuthenticatedHomeserverSynapse homeserver) {
+ /*
+ Remove mappings of SSO IDs
+ Delete media uploaded by user (included avatar images)
+ Delete sent and received messages
+ Remove the user's creation (registration) timestamp
+ Remove rate limit overrides
+ Remove from monthly active users
+ Remove user's consent information (consent version and timestamp)
+ */
+ public async Task CleanupUser(string mxid) {
+ // change the user's password to a random one
+ var newPassword = Guid.NewGuid().ToString();
+ await homeserver.Admin.ResetPasswordAsync(mxid, newPassword, true);
+ await homeserver.Admin.DeleteAllMessages(mxid);
+
+ }
+ private async Task RunUserTasks(string mxid) {
+ var auth = await homeserver.Admin.LoginUserAsync(mxid, TimeSpan.FromDays(1));
+ var userHs = new AuthenticatedHomeserverSynapse(homeserver.ServerName, homeserver.WellKnownUris, null, auth.AccessToken);
+ await userHs.Initialise();
+
+
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/RemoteHomeServer.cs b/LibMatrix/Homeservers/RemoteHomeServer.cs
index f9e3d04..f0b35f9 100644
--- a/LibMatrix/Homeservers/RemoteHomeServer.cs
+++ b/LibMatrix/Homeservers/RemoteHomeServer.cs
@@ -1,35 +1,36 @@
using System.Net.Http.Json;
+using System.Text;
using System.Text.Json;
-using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Web;
using ArcaneLibs.Extensions;
using LibMatrix.Extensions;
using LibMatrix.Responses;
using LibMatrix.Services;
-using Microsoft.Extensions.Logging.Abstractions;
namespace LibMatrix.Homeservers;
public class RemoteHomeserver {
- public RemoteHomeserver(string baseUrl, HomeserverResolverService.WellKnownUris wellKnownUris, string? proxy) {
+ public RemoteHomeserver(string serverName, HomeserverResolverService.WellKnownUris wellKnownUris, string? proxy) {
if (string.IsNullOrWhiteSpace(proxy))
proxy = null;
- BaseUrl = baseUrl;
+ ServerNameOrUrl = serverName;
WellKnownUris = wellKnownUris;
+ Proxy = proxy;
ClientHttpClient = new MatrixHttpClient {
- BaseAddress = new Uri(proxy?.TrimEnd('/') ?? wellKnownUris.Client?.TrimEnd('/') ?? throw new InvalidOperationException($"No client URI for {baseUrl}!")),
+ BaseAddress = new Uri(proxy?.TrimEnd('/') ?? wellKnownUris.Client?.TrimEnd('/') ?? throw new InvalidOperationException($"No client URI for {serverName}!")),
// Timeout = TimeSpan.FromSeconds(300) // TODO: Re-implement this
};
- if (proxy is not null) ClientHttpClient.DefaultRequestHeaders.Add("MXAE_UPSTREAM", baseUrl);
+ if (proxy is not null) ClientHttpClient.DefaultRequestHeaders.Add("MXAE_UPSTREAM", serverName);
if (!string.IsNullOrWhiteSpace(wellKnownUris.Server))
FederationClient = new FederationClient(WellKnownUris.Server!, proxy);
Auth = new(this);
}
private Dictionary<string, object> _profileCache { get; set; } = new();
- public string BaseUrl { get; }
+ public string ServerNameOrUrl { get; }
+ public string? Proxy { get; }
[JsonIgnore]
public MatrixHttpClient ClientHttpClient { get; set; }
@@ -51,16 +52,18 @@ public class RemoteHomeserver {
var resp = await ClientHttpClient.GetAsync($"/_matrix/client/v3/profile/{HttpUtility.UrlEncode(mxid)}");
var data = await resp.Content.ReadFromJsonAsync<UserProfileResponse>();
if (!resp.IsSuccessStatusCode) Console.WriteLine("Profile: " + data);
- _profileCache[mxid] = data;
+ _profileCache[mxid] = data ?? throw new InvalidOperationException($"Could not get profile for {mxid}");
return data;
}
+
+ // TODO: Do we need to support retrieving individual profile properties? Is there any use for that besides just getting the full profile?
public async Task<ClientVersionsResponse> GetClientVersionsAsync() {
var resp = await ClientHttpClient.GetAsync($"/_matrix/client/versions");
var data = await resp.Content.ReadFromJsonAsync<ClientVersionsResponse>();
if (!resp.IsSuccessStatusCode) Console.WriteLine("ClientVersions: " + data);
- return data;
+ return data ?? throw new InvalidOperationException("ClientVersionsResponse is null");
}
public async Task<AliasResult> ResolveRoomAliasAsync(string alias) {
@@ -68,7 +71,16 @@ public class RemoteHomeserver {
var data = await resp.Content.ReadFromJsonAsync<AliasResult>();
//var text = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) Console.WriteLine("ResolveAlias: " + data.ToJson());
- return data;
+ return data ?? throw new InvalidOperationException($"Could not resolve alias {alias}");
+ }
+
+ public Task<PublicRoomDirectoryResult> GetPublicRoomsAsync(int limit = 100, string? server = null, string? since = null) =>
+ ClientHttpClient.GetFromJsonAsync<PublicRoomDirectoryResult>(buildUriWithParams("/_matrix/client/v3/publicRooms", (nameof(limit), true, limit),
+ (nameof(server), !string.IsNullOrWhiteSpace(server), server), (nameof(since), !string.IsNullOrWhiteSpace(since), since)));
+
+ // TODO: move this somewhere else
+ private string buildUriWithParams(string url, params (string name, bool include, object? value)[] values) {
+ return url + "?" + string.Join("&", values.Where(x => x.include));
}
#region Authentication
@@ -80,12 +92,11 @@ public class RemoteHomeserver {
type = "m.id.user",
user = username
},
- password = password,
+ password,
initial_device_display_name = deviceName
});
var data = await resp.Content.ReadFromJsonAsync<LoginResponse>();
- if (!resp.IsSuccessStatusCode) Console.WriteLine("Login: " + data.ToJson());
- return data;
+ return data ?? throw new InvalidOperationException("LoginResponse is null");
}
public async Task<LoginResponse> RegisterAsync(string username, string password, string? deviceName = null) {
@@ -101,26 +112,64 @@ public class RemoteHomeserver {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
var data = await resp.Content.ReadFromJsonAsync<LoginResponse>();
- if (!resp.IsSuccessStatusCode) Console.WriteLine("Register: " + data.ToJson());
- return data;
+ return data ?? throw new InvalidOperationException("LoginResponse is null");
}
#endregion
[Obsolete("This call uses the deprecated unauthenticated media endpoints, please switch to the relevant AuthenticatedHomeserver methods instead.", true)]
- public string? ResolveMediaUri(string? mxcUri) {
- if (mxcUri is null) return null;
- if (mxcUri.StartsWith("https://")) return mxcUri;
- return $"{ClientHttpClient.BaseAddress}/_matrix/media/v3/download/{mxcUri.Replace("mxc://", "")}".Replace("//_matrix", "/_matrix");
- }
+ public virtual string? ResolveMediaUri(string? mxcUri) => null;
public UserInteractiveAuthClient Auth;
}
+public class PublicRoomDirectoryResult {
+ [JsonPropertyName("chunk")]
+ public List<PublicRoomListItem> Chunk { get; set; }
+
+ [JsonPropertyName("next_batch")]
+ public string? NextBatch { get; set; }
+
+ [JsonPropertyName("prev_batch")]
+ public string? PrevBatch { get; set; }
+
+ [JsonPropertyName("total_room_count_estimate")]
+ public int TotalRoomCountEstimate { get; set; }
+
+ public class PublicRoomListItem {
+ [JsonPropertyName("avatar_url")]
+ public string? AvatarUrl { get; set; }
+
+ [JsonPropertyName("canonical_alias")]
+ public string? CanonicalAlias { get; set; }
+
+ [JsonPropertyName("guest_can_join")]
+ public bool GuestCanJoin { get; set; }
+
+ [JsonPropertyName("join_rule")]
+ public string JoinRule { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("num_joined_members")]
+ public int NumJoinedMembers { get; set; }
+
+ [JsonPropertyName("room_id")]
+ public string RoomId { get; set; }
+
+ [JsonPropertyName("topic")]
+ public string? Topic { get; set; }
+
+ [JsonPropertyName("world_readable")]
+ public bool WorldReadable { get; set; }
+ }
+}
+
public class AliasResult {
[JsonPropertyName("room_id")]
- public string RoomId { get; set; } = null!;
+ public string RoomId { get; set; }
[JsonPropertyName("servers")]
- public List<string> Servers { get; set; } = null!;
+ public List<string> Servers { get; set; }
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/UserInteractiveAuthClient.cs b/LibMatrix/Homeservers/UserInteractiveAuthClient.cs
index 8be2cb9..8de01d2 100644
--- a/LibMatrix/Homeservers/UserInteractiveAuthClient.cs
+++ b/LibMatrix/Homeservers/UserInteractiveAuthClient.cs
@@ -1,7 +1,5 @@
-using System.Net.Http.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
-using ArcaneLibs.Extensions;
using LibMatrix.Responses;
namespace LibMatrix.Homeservers;
@@ -51,27 +49,27 @@ public class UserInteractiveAuthClient {
internal class RegisterFlowsResponse {
[JsonPropertyName("session")]
- public string Session { get; set; } = null!;
+ public string Session { get; set; }
[JsonPropertyName("flows")]
- public List<RegisterFlow> Flows { get; set; } = null!;
+ public List<RegisterFlow> Flows { get; set; }
[JsonPropertyName("params")]
- public JsonObject Params { get; set; } = null!;
+ public JsonObject Params { get; set; }
public class RegisterFlow {
[JsonPropertyName("stages")]
- public List<string> Stages { get; set; } = null!;
+ public List<string> Stages { get; set; }
}
}
internal class LoginFlowsResponse {
[JsonPropertyName("flows")]
- public List<LoginFlow> Flows { get; set; } = null!;
+ public List<LoginFlow> Flows { get; set; }
public class LoginFlow {
[JsonPropertyName("type")]
- public string Type { get; set; } = null!;
+ public string Type { get; set; }
}
}
diff --git a/LibMatrix/Interfaces/Services/IStorageProvider.cs b/LibMatrix/Interfaces/Services/IStorageProvider.cs
index 165e7df..f7e5488 100644
--- a/LibMatrix/Interfaces/Services/IStorageProvider.cs
+++ b/LibMatrix/Interfaces/Services/IStorageProvider.cs
@@ -1,3 +1,7 @@
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using LibMatrix.Responses;
+
namespace LibMatrix.Interfaces.Services;
public interface IStorageProvider {
@@ -17,6 +21,13 @@ public interface IStorageProvider {
Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement SaveObject<T>(key, value)!");
throw new NotImplementedException();
}
+
+ public Task SaveObjectAsync<T>(string key, T value, JsonTypeInfo<T> jsonTypeInfo) {
+ Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement SaveObjectAsync<T>(key, value, typeInfo), using default implementation w/ MemoryStream!");
+ var ms = new MemoryStream();
+ JsonSerializer.Serialize(ms, value, jsonTypeInfo);
+ return SaveStreamAsync(key, ms);
+ }
// load
public Task<T?> LoadObjectAsync<T>(string key) {
@@ -24,6 +35,12 @@ public interface IStorageProvider {
throw new NotImplementedException();
}
+ public async Task<T?> LoadObjectAsync<T>(string key, JsonTypeInfo<T> jsonTypeInfo) {
+ Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement SaveObject<T>(key, typeInfo), using default implementation!");
+ await using var stream = await LoadStreamAsync(key);
+ return JsonSerializer.Deserialize(stream!, jsonTypeInfo);
+ }
+
// check if exists
public Task<bool> ObjectExistsAsync(string key) {
Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement ObjectExists(key)!");
@@ -31,7 +48,7 @@ public interface IStorageProvider {
}
// get all keys
- public Task<List<string>> GetAllKeysAsync() {
+ public Task<IEnumerable<string>> GetAllKeysAsync() {
Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement GetAllKeys()!");
throw new NotImplementedException();
}
@@ -53,4 +70,18 @@ public interface IStorageProvider {
Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement LoadStream(key)!");
throw new NotImplementedException();
}
+
+ // copy
+ public async Task CopyObjectAsync(string sourceKey, string destKey) {
+ Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement CopyObject(sourceKey, destKey), using load + save!");
+ var data = await LoadObjectAsync<object>(sourceKey);
+ await SaveObjectAsync(destKey, data);
+ }
+
+ // move
+ public async Task MoveObjectAsync(string sourceKey, string destKey) {
+ Console.WriteLine($"StorageProvider<{GetType().Name}> does not implement MoveObject(sourceKey, destKey), using copy + delete!");
+ await CopyObjectAsync(sourceKey, destKey);
+ await DeleteObjectAsync(sourceKey);
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/LibMatrix.csproj b/LibMatrix/LibMatrix.csproj
index d0511ea..62bb48f 100644
--- a/LibMatrix/LibMatrix.csproj
+++ b/LibMatrix/LibMatrix.csproj
@@ -1,33 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net8.0</TargetFramework>
+ <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<Optimize>true</Optimize>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <!-- Required for UnicodeJsonEncoder... -->
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
- <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
+ <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.1"/>
+ <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1"/>
+ <ProjectReference Include="..\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj"/>
</ItemGroup>
<ItemGroup>
- <ProjectReference Condition="Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')" Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj"/>
- <!-- This is dangerous, but eases development since locking the version will drift out of sync without noticing,
- which causes build errors due to missing functions.
- Using the NuGet version in development is annoying due to delays between pushing and being able to consume.
- If you want to use a time-appropriate version of the library, recursively clone https://cgit.rory.gay/matrix/MatrixUtils.git
- instead, since this will be locked by the MatrixUtils project, which contains both LibMatrix and ArcaneLibs as a submodule. -->
- <PackageReference Condition="!Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')" Include="ArcaneLibs" Version="*-preview*"/>
- <ProjectReference Include="..\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj"/>
+ <!-- <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250313-104848" Condition="'$(Configuration)' == 'Release'" />-->
+ <!-- <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/>-->
+ <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj"/>
</ItemGroup>
- <Target Name="ArcaneLibsNugetWarning" AfterTargets="AfterBuild">
- <Warning Text="ArcaneLibs is being referenced from NuGet, which is dangerous. Please read the warning in LibMatrix.csproj!" Condition="!Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')"/>
- </Target>
-
</Project>
diff --git a/LibMatrix/LibMatrixException.cs b/LibMatrix/LibMatrixException.cs
index 5854826..27cfc2a 100644
--- a/LibMatrix/LibMatrixException.cs
+++ b/LibMatrix/LibMatrixException.cs
@@ -1,5 +1,7 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ArcaneLibs.Extensions;
+// ReSharper disable MemberCanBePrivate.Global
namespace LibMatrix;
@@ -20,6 +22,7 @@ public class LibMatrixException : Exception {
_ => $"Unknown error: {GetAsObject().ToJson(ignoreNull: true)}"
}}\nError: {Error}";
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Follows spec naming")]
public static class ErrorCodes {
public const string M_NOT_FOUND = "M_NOT_FOUND";
public const string M_UNSUPPORTED = "M_UNSUPPORTED";
diff --git a/LibMatrix/MatrixException.cs b/LibMatrix/MatrixException.cs
index eb207da..519f99e 100644
--- a/LibMatrix/MatrixException.cs
+++ b/LibMatrix/MatrixException.cs
@@ -1,5 +1,7 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ArcaneLibs.Extensions;
+// ReSharper disable MemberCanBePrivate.Global
namespace LibMatrix;
@@ -17,51 +19,56 @@ public class MatrixException : Exception {
[JsonPropertyName("retry_after_ms")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? RetryAfterMs { get; set; }
-
+
public string RawContent { get; set; }
public object GetAsObject() => new { errcode = ErrorCode, error = Error, soft_logout = SoftLogout, retry_after_ms = RetryAfterMs };
public string GetAsJson() => GetAsObject().ToJson(ignoreNull: true);
public override string Message =>
- $"{ErrorCode}: {ErrorCode switch {
- // common
- "M_FORBIDDEN" => $"You do not have permission to perform this action: {Error}",
- "M_UNKNOWN_TOKEN" => $"The access token specified was not recognised: {Error}{(SoftLogout == true ? " (soft logout)" : "")}",
- "M_MISSING_TOKEN" => $"No access token was specified: {Error}",
- "M_BAD_JSON" => $"Request contained valid JSON, but it was malformed in some way: {Error}",
- "M_NOT_JSON" => $"Request did not contain valid JSON: {Error}",
- "M_NOT_FOUND" => $"The requested resource was not found: {Error}",
- "M_LIMIT_EXCEEDED" => $"Too many requests have been sent in a short period of time. Wait a while then try again: {Error}",
- "M_UNRECOGNISED" => $"The server did not recognise the request: {Error}",
- "M_UNKOWN" => $"The server encountered an unexpected error: {Error}",
- // endpoint specific
- "M_UNAUTHORIZED" => $"The request did not contain valid authentication information for the target of the request: {Error}",
- "M_USER_DEACTIVATED" => $"The user ID associated with the request has been deactivated: {Error}",
- "M_USER_IN_USE" => $"The user ID associated with the request is already in use: {Error}",
- "M_INVALID_USERNAME" => $"The requested user ID is not valid: {Error}",
- "M_ROOM_IN_USE" => $"The room alias requested is already taken: {Error}",
- "M_INVALID_ROOM_STATE" => $"The room associated with the request is not in a valid state to perform the request: {Error}",
- "M_THREEPID_IN_USE" => $"The threepid requested is already associated with a user ID on this server: {Error}",
- "M_THREEPID_NOT_FOUND" => $"The threepid requested is not associated with any user ID: {Error}",
- "M_THREEPID_AUTH_FAILED" => $"The provided threepid and/or token was invalid: {Error}",
- "M_THREEPID_DENIED" => $"The homeserver does not permit the third party identifier in question: {Error}",
- "M_SERVER_NOT_TRUSTED" => $"The homeserver does not trust the identity server: {Error}",
- "M_UNSUPPORTED_ROOM_VERSION" => $"The room version is not supported: {Error}",
- "M_INCOMPATIBLE_ROOM_VERSION" => $"The room version is incompatible: {Error}",
- "M_BAD_STATE" => $"The request was invalid because the state was invalid: {Error}",
- "M_GUEST_ACCESS_FORBIDDEN" => $"Guest access is forbidden: {Error}",
- "M_CAPTCHA_NEEDED" => $"Captcha needed: {Error}",
- "M_CAPTCHA_INVALID" => $"Captcha invalid: {Error}",
- "M_MISSING_PARAM" => $"Missing parameter: {Error}",
- "M_INVALID_PARAM" => $"Invalid parameter: {Error}",
- "M_TOO_LARGE" => $"The request or entity was too large: {Error}",
- "M_EXCLUSIVE" => $"The resource being requested is reserved by an application service, or the application service making the request has not created the resource: {Error}",
- "M_RESOURCE_LIMIT_EXCEEDED" => $"Exceeded resource limit: {Error}",
- "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM" => $"Cannot leave server notice room: {Error}",
- _ => $"Unknown error: {new { ErrorCode, Error, SoftLogout, RetryAfterMs }.ToJson(ignoreNull: true)}"
- }}";
+ $"{ErrorCode}: " +
+ (!string.IsNullOrWhiteSpace(Error)
+ ? Error
+ : ErrorCode switch {
+ // common
+ "M_FORBIDDEN" => $"You do not have permission to perform this action: {Error}",
+ "M_UNKNOWN_TOKEN" => $"The access token specified was not recognised: {Error}{(SoftLogout == true ? " (soft logout)" : "")}",
+ "M_MISSING_TOKEN" => $"No access token was specified: {Error}",
+ "M_BAD_JSON" => $"Request contained valid JSON, but it was malformed in some way: {Error}",
+ "M_NOT_JSON" => $"Request did not contain valid JSON: {Error}",
+ "M_NOT_FOUND" => $"The requested resource was not found: {Error}",
+ "M_LIMIT_EXCEEDED" => $"Too many requests have been sent in a short period of time. Wait a while then try again: {Error}",
+ "M_UNRECOGNISED" => $"The server did not recognise the request: {Error}",
+ "M_UNKOWN" => $"The server encountered an unexpected error: {Error}",
+ // endpoint specific
+ "M_UNAUTHORIZED" => $"The request did not contain valid authentication information for the target of the request: {Error}",
+ "M_USER_DEACTIVATED" => $"The user ID associated with the request has been deactivated: {Error}",
+ "M_USER_IN_USE" => $"The user ID associated with the request is already in use: {Error}",
+ "M_INVALID_USERNAME" => $"The requested user ID is not valid: {Error}",
+ "M_ROOM_IN_USE" => $"The room alias requested is already taken: {Error}",
+ "M_INVALID_ROOM_STATE" => $"The room associated with the request is not in a valid state to perform the request: {Error}",
+ "M_THREEPID_IN_USE" => $"The threepid requested is already associated with a user ID on this server: {Error}",
+ "M_THREEPID_NOT_FOUND" => $"The threepid requested is not associated with any user ID: {Error}",
+ "M_THREEPID_AUTH_FAILED" => $"The provided threepid and/or token was invalid: {Error}",
+ "M_THREEPID_DENIED" => $"The homeserver does not permit the third party identifier in question: {Error}",
+ "M_SERVER_NOT_TRUSTED" => $"The homeserver does not trust the identity server: {Error}",
+ "M_UNSUPPORTED_ROOM_VERSION" => $"The room version is not supported: {Error}",
+ "M_INCOMPATIBLE_ROOM_VERSION" => $"The room version is incompatible: {Error}",
+ "M_BAD_STATE" => $"The request was invalid because the state was invalid: {Error}",
+ "M_GUEST_ACCESS_FORBIDDEN" => $"Guest access is forbidden: {Error}",
+ "M_CAPTCHA_NEEDED" => $"Captcha needed: {Error}",
+ "M_CAPTCHA_INVALID" => $"Captcha invalid: {Error}",
+ "M_MISSING_PARAM" => $"Missing parameter: {Error}",
+ "M_INVALID_PARAM" => $"Invalid parameter: {Error}",
+ "M_TOO_LARGE" => $"The request or entity was too large: {Error}",
+ "M_EXCLUSIVE" =>
+ $"The resource being requested is reserved by an application service, or the application service making the request has not created the resource: {Error}",
+ "M_RESOURCE_LIMIT_EXCEEDED" => $"Exceeded resource limit: {Error}",
+ "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM" => $"Cannot leave server notice room: {Error}",
+ _ => $"Unknown error: {new { ErrorCode, Error, SoftLogout, RetryAfterMs }.ToJson(ignoreNull: true)}"
+ });
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Follows spec naming")]
public static class ErrorCodes {
public const string M_FORBIDDEN = "M_FORBIDDEN";
public const string M_UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN";
diff --git a/LibMatrix/MxcUri.cs b/LibMatrix/MxcUri.cs
new file mode 100644
index 0000000..875ae53
--- /dev/null
+++ b/LibMatrix/MxcUri.cs
@@ -0,0 +1,43 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace LibMatrix;
+
+public class MxcUri {
+ public required string ServerName { get; set; }
+ public required string MediaId { get; set; }
+
+ public static MxcUri Parse([StringSyntax("Uri")] string mxcUri) {
+ if (!mxcUri.StartsWith("mxc://")) throw new ArgumentException("Matrix Content URIs must start with 'mxc://'", nameof(mxcUri));
+ var parts = mxcUri[6..].Split('/');
+ if (parts.Length != 2) throw new ArgumentException($"Invalid Matrix Content URI '{mxcUri}' passed! Matrix Content URIs must exist of only 2 parts!", nameof(mxcUri));
+ return new MxcUri {
+ ServerName = parts[0],
+ MediaId = parts[1]
+ };
+ }
+
+ public static implicit operator MxcUri(string mxcUri) => Parse(mxcUri);
+ public static implicit operator string(MxcUri mxcUri) => $"mxc://{mxcUri.ServerName}/{mxcUri.MediaId}";
+ public static implicit operator (string, string)(MxcUri mxcUri) => (mxcUri.ServerName, mxcUri.MediaId);
+ public static implicit operator MxcUri((string serverName, string mediaId) mxcUri) => (mxcUri.serverName, mxcUri.mediaId);
+ // public override string ToString() => $"mxc://{ServerName}/{MediaId}";
+
+ public string ToDownloadUri(string? baseUrl = null, string? filename = null, int? timeout = null) {
+ var uri = $"{baseUrl}/_matrix/client/v1/media/download/{ServerName}/{MediaId}";
+ if (filename is not null) uri += $"/{filename}";
+ if (timeout is not null) uri += $"?timeout={timeout}";
+ return uri;
+ }
+
+ public string ToLegacyDownloadUri(string? baseUrl = null, string? filename = null, int? timeout = null) {
+ var uri = $"{baseUrl}/_matrix/media/v3/download/{ServerName}/{MediaId}";
+ if (filename is not null) uri += $"/{filename}";
+ if (timeout is not null) uri += $"?timeout_ms={timeout}";
+ return uri;
+ }
+
+ public void Deconstruct(out string serverName, out string mediaId) {
+ serverName = ServerName;
+ mediaId = MediaId;
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Responses/CreateRoomRequest.cs b/LibMatrix/Responses/CreateRoomRequest.cs
index 6f47183..6933622 100644
--- a/LibMatrix/Responses/CreateRoomRequest.cs
+++ b/LibMatrix/Responses/CreateRoomRequest.cs
@@ -3,7 +3,7 @@ using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using LibMatrix.EventTypes;
-using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
using LibMatrix.Homeservers;
namespace LibMatrix.Responses;
@@ -26,7 +26,7 @@ public class CreateRoomRequest {
//we dont want to use this, we want more control
// [JsonPropertyName("preset")]
- // public string Preset { get; set; } = null!;
+ // public string Preset { get; set; }
[JsonPropertyName("initial_state")]
public List<StateEvent>? InitialState { get; set; }
@@ -35,10 +35,11 @@ public class CreateRoomRequest {
/// One of: ["public", "private"]
/// </summary>
[JsonPropertyName("visibility")]
+ // ReSharper disable once UnusedAutoPropertyAccessor.Global
public string? Visibility { get; set; }
[JsonPropertyName("power_level_content_override")]
- public RoomPowerLevelEventContent? PowerLevelContentOverride { get; set; } = null!;
+ public RoomPowerLevelEventContent? PowerLevelContentOverride { get; set; }
[JsonPropertyName("creation_content")]
public JsonObject CreationContent { get; set; } = new();
@@ -46,15 +47,17 @@ public class CreateRoomRequest {
[JsonPropertyName("invite")]
public List<string>? Invite { get; set; }
+ public string? RoomVersion { get; set; }
+
/// <summary>
/// For use only when you can't use the CreationContent property
/// </summary>
- public StateEvent this[string eventType, string eventKey = ""] {
+ public StateEvent? this[string eventType, string eventKey = ""] {
get {
- var stateEvent = InitialState.FirstOrDefault(x => x.Type == eventType && x.StateKey == eventKey);
+ var stateEvent = InitialState?.FirstOrDefault(x => x.Type == eventType && x.StateKey == eventKey);
if (stateEvent == null)
- InitialState.Add(stateEvent = new StateEvent {
+ InitialState?.Add(stateEvent = new StateEvent {
Type = eventType,
StateKey = eventKey,
TypedContent = (EventContent)Activator.CreateInstance(
@@ -77,7 +80,7 @@ public class CreateRoomRequest {
public Dictionary<string, string> Validate() {
Dictionary<string, string> errors = new();
- if (!Regex.IsMatch(RoomAliasName, @"[a-zA-Z0-9_\-]+$"))
+ if (!string.IsNullOrWhiteSpace(RoomAliasName) && !Regex.IsMatch(RoomAliasName, @"[a-zA-Z0-9_\-]+$"))
errors.Add("room_alias_name",
"Room alias name must only contain letters, numbers, underscores, and hyphens.");
@@ -97,7 +100,7 @@ public class CreateRoomRequest {
Invite = 25,
StateDefault = 10,
Redact = 50,
- NotificationsPl = new RoomPowerLevelEventContent.NotificationsPL {
+ NotificationsPl = new RoomPowerLevelEventContent.NotificationsPowerLevels {
Room = 10
},
Events = new Dictionary<string, long> {
@@ -137,7 +140,7 @@ public class CreateRoomRequest {
Invite = 25,
StateDefault = 10,
Redact = 50,
- NotificationsPl = new RoomPowerLevelEventContent.NotificationsPL {
+ NotificationsPl = new RoomPowerLevelEventContent.NotificationsPowerLevels {
Room = 10
},
Events = new Dictionary<string, long> {
diff --git a/LibMatrix/Responses/DeviceKeysUploadRequest.cs b/LibMatrix/Responses/DeviceKeysUploadRequest.cs
new file mode 100644
index 0000000..c93c4c6
--- /dev/null
+++ b/LibMatrix/Responses/DeviceKeysUploadRequest.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Responses;
+
+public class DeviceKeysUploadRequest {
+ [JsonPropertyName("device_keys")]
+ public DeviceKeysSchema DeviceKeys { get; set; }
+
+
+ [JsonPropertyName("one_time_keys")]
+ public Dictionary<string, OneTimeKey> OneTimeKeys { get; set; }
+
+ public class DeviceKeysSchema {
+ [JsonPropertyName("algorithms")]
+ public List<string> Algorithms { get; set; }
+ }
+ public class OneTimeKey {
+ [JsonPropertyName("key")]
+ public string Key { get; set; }
+
+ [JsonPropertyName("signatures")]
+ public Dictionary<string, Dictionary<string, string>> Signatures { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/EventIdResponse.cs b/LibMatrix/Responses/EventIdResponse.cs
index 4d715a4..9e23210 100644
--- a/LibMatrix/EventIdResponse.cs
+++ b/LibMatrix/Responses/EventIdResponse.cs
@@ -1,8 +1,8 @@
using System.Text.Json.Serialization;
-namespace LibMatrix;
+namespace LibMatrix.Responses;
public class EventIdResponse {
[JsonPropertyName("event_id")]
- public string EventId { get; set; }
+ public required string EventId { get; set; }
}
\ No newline at end of file
diff --git a/LibMatrix/Responses/LoginResponse.cs b/LibMatrix/Responses/LoginResponse.cs
index 28fb245..1944276 100644
--- a/LibMatrix/Responses/LoginResponse.cs
+++ b/LibMatrix/Responses/LoginResponse.cs
@@ -1,30 +1,24 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
-using LibMatrix.Homeservers;
namespace LibMatrix.Responses;
public class LoginResponse {
[JsonPropertyName("access_token")]
- public string AccessToken { get; set; } = null!;
+ public string AccessToken { get; set; }
[JsonPropertyName("device_id")]
- public string DeviceId { get; set; } = null!;
-
- private string? _homeserver;
+ public string DeviceId { get; set; }
[JsonPropertyName("home_server")]
+ [field: AllowNull, MaybeNull]
public string Homeserver {
- get => _homeserver ?? UserId.Split(':', 2).Last();
- protected init => _homeserver = value;
+ get => field ?? UserId.Split(':', 2).Last();
+ set;
}
[JsonPropertyName("user_id")]
- public string UserId { get; set; } = null!;
-
- // public async Task<AuthenticatedHomeserverGeneric> GetAuthenticatedHomeserver(string? proxy = null) {
- // var urls = await new HomeserverResolverService().ResolveHomeserverFromWellKnown(Homeserver);
- // await AuthenticatedHomeserverGeneric.Create<AuthenticatedHomeserverGeneric>(Homeserver, AccessToken, proxy);
- // }
+ public string UserId { get; set; }
}
public class LoginRequest {
diff --git a/LibMatrix/MessagesResponse.cs b/LibMatrix/Responses/MessagesResponse.cs
index 526da74..4912add 100644
--- a/LibMatrix/MessagesResponse.cs
+++ b/LibMatrix/Responses/MessagesResponse.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix;
+namespace LibMatrix.Responses;
public class MessagesResponse {
[JsonPropertyName("start")]
diff --git a/LibMatrix/Responses/SyncResponse.cs b/LibMatrix/Responses/SyncResponse.cs
index e4addb6..d79e820 100644
--- a/LibMatrix/Responses/SyncResponse.cs
+++ b/LibMatrix/Responses/SyncResponse.cs
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
namespace LibMatrix.Responses;
@@ -8,16 +9,16 @@ internal partial class SyncResponseSerializerContext : JsonSerializerContext { }
public class SyncResponse {
[JsonPropertyName("next_batch")]
- public string NextBatch { get; set; } = null!;
+ public string NextBatch { get; set; }
[JsonPropertyName("account_data")]
public EventList? AccountData { get; set; }
[JsonPropertyName("presence")]
- public PresenceDataStructure? Presence { get; set; }
+ public EventList? Presence { get; set; }
[JsonPropertyName("device_one_time_keys_count")]
- public Dictionary<string, int>? DeviceOneTimeKeysCount { get; set; } = null!;
+ public Dictionary<string, int>? DeviceOneTimeKeysCount { get; set; }
[JsonPropertyName("rooms")]
public RoomsDataStructure? Rooms { get; set; }
@@ -28,6 +29,9 @@ public class SyncResponse {
[JsonPropertyName("device_lists")]
public DeviceListsDataStructure? DeviceLists { get; set; }
+ [JsonPropertyName("gay.rory.libmatrix.msc4222_sync_type")]
+ public Msc4222SyncType Msc4222Method { get; set; } = Msc4222SyncType.None;
+
public class DeviceListsDataStructure {
[JsonPropertyName("changed")]
public List<string>? Changed { get; set; }
@@ -39,7 +43,7 @@ public class SyncResponse {
// supporting classes
public class PresenceDataStructure {
[JsonPropertyName("events")]
- public List<StateEventResponse> Events { get; set; } = new();
+ public List<StateEventResponse>? Events { get; set; }
}
public class RoomsDataStructure {
@@ -61,6 +65,22 @@ public class SyncResponse {
[JsonPropertyName("state")]
public EventList? State { get; set; }
+
+ [JsonPropertyName("state_after")]
+ public EventList? StateAfter { get; set; }
+
+ [Obsolete("This property is only used for de/serialisation")]
+ [JsonPropertyName("org.matrix.msc4222.state_after")]
+ public EventList? StateAfterUnstable {
+ get => StateAfter;
+ set => StateAfter = value;
+ }
+
+ public override string ToString() {
+ var lastEvent = Timeline?.Events?.LastOrDefault(x => x.Type == "m.room.member");
+ var membership = (lastEvent?.TypedContent as RoomMemberEventContent);
+ return $"LeftRoomDataStructure: {lastEvent?.Sender} {membership?.Membership} ({membership?.Reason})";
+ }
}
public class JoinedRoomDataStructure {
@@ -70,6 +90,16 @@ public class SyncResponse {
[JsonPropertyName("state")]
public EventList? State { get; set; }
+ [JsonPropertyName("state_after")]
+ public EventList? StateAfter { get; set; }
+
+ [Obsolete("This property is only used for de/serialisation")]
+ [JsonPropertyName("org.matrix.msc4222.state_after")]
+ public EventList? StateAfterUnstable {
+ get => StateAfter;
+ set => StateAfter = value;
+ }
+
[JsonPropertyName("account_data")]
public EventList? AccountData { get; set; }
@@ -82,7 +112,7 @@ public class SyncResponse {
[JsonPropertyName("summary")]
public SummaryDataStructure? Summary { get; set; }
- public class TimelineDataStructure {
+ public class TimelineDataStructure : EventList {
public TimelineDataStructure() { }
public TimelineDataStructure(List<StateEventResponse>? events, bool? limited) {
@@ -90,8 +120,8 @@ public class SyncResponse {
Limited = limited;
}
- [JsonPropertyName("events")]
- public List<StateEventResponse>? Events { get; set; }
+ // [JsonPropertyName("events")]
+ // public List<StateEventResponse>? Events { get; set; }
[JsonPropertyName("prev_batch")]
public string? PrevBatch { get; set; }
@@ -125,4 +155,22 @@ public class SyncResponse {
public EventList? InviteState { get; set; }
}
}
+
+ public long GetDerivedSyncTime() {
+ return ((long[]) [
+ AccountData?.Events?.Max(x => x.OriginServerTs) ?? 0,
+ Presence?.Events?.Max(x => x.OriginServerTs) ?? 0,
+ ToDevice?.Events?.Max(x => x.OriginServerTs) ?? 0,
+ Rooms?.Join?.Values?.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0,
+ Rooms?.Invite?.Values?.Max(x => x.InviteState?.Events?.Max(y => y.OriginServerTs)) ?? 0,
+ Rooms?.Leave?.Values?.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0
+ ]).Max();
+ }
+
+ [JsonConverter(typeof(JsonStringEnumConverter<Msc4222SyncType>))]
+ public enum Msc4222SyncType {
+ None,
+ Server,
+ Emulated
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/Responses/UserDirectoryResponse.cs b/LibMatrix/Responses/UserDirectoryResponse.cs
new file mode 100644
index 0000000..13235d9
--- /dev/null
+++ b/LibMatrix/Responses/UserDirectoryResponse.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Responses;
+
+public class UserDirectoryResponse {
+ [JsonPropertyName("limited")]
+ public bool Limited { get; set; }
+
+ [JsonPropertyName("results")]
+ public List<UserDirectoryResult> Results { get; set; }
+
+ public class UserDirectoryResult {
+ [JsonPropertyName("avatar_url")]
+ public string? AvatarUrl { get; set; }
+
+ [JsonPropertyName("display_name")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("user_id")]
+ public string UserId { get; set; }
+ }
+}
+
+public class UserDirectoryRequest {
+ [JsonPropertyName("search_term")]
+ public string SearchTerm { get; set; }
+
+ [JsonPropertyName("limit")]
+ public int? Limit { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/UserIdAndReason.cs b/LibMatrix/Responses/UserIdAndReason.cs
index 99c9eaf..176cf7c 100644
--- a/LibMatrix/UserIdAndReason.cs
+++ b/LibMatrix/Responses/UserIdAndReason.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix;
+namespace LibMatrix.Responses;
internal class UserIdAndReason(string userId = null!, string reason = null!) {
[JsonPropertyName("user_id")]
diff --git a/LibMatrix/Responses/UserProfileResponse.cs b/LibMatrix/Responses/UserProfileResponse.cs
index 6c9380f..30e4c32 100644
--- a/LibMatrix/Responses/UserProfileResponse.cs
+++ b/LibMatrix/Responses/UserProfileResponse.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using System.Text.Json.Serialization;
namespace LibMatrix.Responses;
@@ -8,4 +9,18 @@ public class UserProfileResponse {
[JsonPropertyName("displayname")]
public string? DisplayName { get; set; }
+
+ // MSC 4133 - Extending User Profile API with Key:Value pairs
+ [JsonExtensionData]
+ public Dictionary<string, JsonElement>? CustomKeys { get; set; }
+
+ public JsonElement? this[string key] {
+ get => CustomKeys?[key];
+ set {
+ if (value is null)
+ CustomKeys?.Remove(key);
+ else
+ (CustomKeys ??= [])[key] = value.Value;
+ }
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/WhoAmIResponse.cs b/LibMatrix/Responses/WhoAmIResponse.cs
index 10fff35..db47152 100644
--- a/LibMatrix/WhoAmIResponse.cs
+++ b/LibMatrix/Responses/WhoAmIResponse.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix;
+namespace LibMatrix.Responses;
public class WhoAmIResponse {
[JsonPropertyName("user_id")]
diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs
index 349ccb5..fd4db4d 100644
--- a/LibMatrix/RoomTypes/GenericRoom.cs
+++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -1,5 +1,4 @@
using System.Collections.Frozen;
-using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -8,13 +7,11 @@ using System.Web;
using ArcaneLibs.Extensions;
using LibMatrix.EventTypes;
using LibMatrix.EventTypes.Spec;
-using LibMatrix.EventTypes.Spec.State;
using LibMatrix.EventTypes.Spec.State.RoomInfo;
using LibMatrix.Filters;
using LibMatrix.Helpers;
using LibMatrix.Homeservers;
-using LibMatrix.Services;
-using Microsoft.Extensions.Logging.Abstractions;
+using LibMatrix.Responses;
namespace LibMatrix.RoomTypes;
@@ -26,8 +23,6 @@ public class GenericRoom {
throw new ArgumentException("Room ID cannot be null or whitespace", nameof(roomId));
Homeserver = homeserver;
RoomId = roomId;
- // if (GetType() != typeof(SpaceRoom))
- if (GetType() == typeof(GenericRoom)) AsSpace = new SpaceRoom(homeserver, RoomId);
}
public string RoomId { get; set; }
@@ -106,7 +101,7 @@ public class GenericRoom {
Console.WriteLine("WARNING: Homeserver does not support getting event ID from state events, falling back to sync");
var sh = new SyncHelper(Homeserver);
var emptyFilter = new SyncFilter.EventFilter(types: [], limit: 1, senders: [], notTypes: ["*"]);
- var emptyStateFilter = new SyncFilter.RoomFilter.StateFilter(types: [], limit: 1, senders: [], notTypes: ["*"], rooms:[]);
+ var emptyStateFilter = new SyncFilter.RoomFilter.StateFilter(types: [], limit: 1, senders: [], notTypes: ["*"], rooms: []);
sh.Filter = new() {
Presence = emptyFilter,
AccountData = emptyFilter,
@@ -121,10 +116,11 @@ public class GenericRoom {
var sync = await sh.SyncAsync();
var state = sync.Rooms.Join[RoomId].State.Events;
var stateEvent = state.FirstOrDefault(x => x.Type == type && x.StateKey == stateKey);
- if (stateEvent is null) throw new LibMatrixException() {
- ErrorCode = LibMatrixException.ErrorCodes.M_NOT_FOUND,
- Error = "State event not found in sync response"
- };
+ if (stateEvent is null)
+ throw new LibMatrixException() {
+ ErrorCode = LibMatrixException.ErrorCodes.M_NOT_FOUND,
+ Error = "State event not found in sync response"
+ };
return stateEvent.EventId;
}
@@ -137,16 +133,17 @@ public class GenericRoom {
return await GetStateEventAsync(type, stateKey);
}
catch (MatrixException e) {
- if (e.ErrorCode == "M_NOT_FOUND") return default;
+ if (e.ErrorCode == "M_NOT_FOUND") return null;
throw;
}
}
- public async Task<MessagesResponse> GetMessagesAsync(string from = "", int? limit = null, string dir = "b", string filter = "") {
+ public async Task<MessagesResponse> GetMessagesAsync(string from = "", int? limit = null, string dir = "b", string? filter = "") {
var url = $"/_matrix/client/v3/rooms/{RoomId}/messages?dir={dir}";
if (!string.IsNullOrWhiteSpace(from)) url += $"&from={from}";
if (limit is not null) url += $"&limit={limit}";
if (!string.IsNullOrWhiteSpace(filter)) url += $"&filter={filter}";
+
var res = await Homeserver.ClientHttpClient.GetFromJsonAsync<MessagesResponse>(url);
return res;
}
@@ -154,8 +151,8 @@ public class GenericRoom {
/// <summary>
/// Same as <see cref="GetMessagesAsync"/>, except keeps fetching more responses until the beginning of the room is found, or the target message limit is reached
/// </summary>
- public async IAsyncEnumerable<MessagesResponse> GetManyMessagesAsync(string from = "", int limit = 100, string dir = "b", string filter = "", bool includeState = true,
- bool fixForward = false, int chunkSize = 100) {
+ public async IAsyncEnumerable<MessagesResponse> GetManyMessagesAsync(string from = "", int limit = int.MaxValue, string dir = "b", string filter = "", bool includeState = true,
+ bool fixForward = false, int chunkSize = 250) {
if (dir == "f" && fixForward) {
var concat = new List<MessagesResponse>();
while (true) {
@@ -207,7 +204,7 @@ public class GenericRoom {
public async Task<string?> GetNameAsync() => (await GetStateOrNullAsync<RoomNameEventContent>("m.room.name"))?.Name;
- public async Task<RoomIdResponse> JoinAsync(string[]? homeservers = null, string? reason = null, bool checkIfAlreadyMember = true) {
+ public async Task<RoomIdResponse> JoinAsync(IEnumerable<string>? homeservers = null, string? reason = null, bool checkIfAlreadyMember = true) {
if (checkIfAlreadyMember)
try {
var ser = await GetStateEventOrNullAsync(RoomMemberEventContent.EventId, Homeserver.UserId);
@@ -216,68 +213,74 @@ public class GenericRoom {
RoomId = RoomId
};
}
- catch { } //ignore
+ catch {
+ // ignored
+ }
var joinUrl = $"/_matrix/client/v3/join/{HttpUtility.UrlEncode(RoomId)}";
- Console.WriteLine($"Calling {joinUrl} with {homeservers?.Length ?? 0} via's...");
- if (homeservers == null || homeservers.Length == 0) homeservers = new[] { RoomId.Split(':', 2)[1] };
- var fullJoinUrl = $"{joinUrl}?server_name=" + string.Join("&server_name=", homeservers);
+
+ var materialisedHomeservers = homeservers as string[] ?? homeservers?.ToArray() ?? [];
+ if (!materialisedHomeservers.Any()) materialisedHomeservers = [RoomId.Split(':', 2)[1]];
+
+ Console.WriteLine($"Calling {joinUrl} with {materialisedHomeservers.Length} via(s)...");
+
+ var fullJoinUrl = $"{joinUrl}?server_name=" + string.Join("&server_name=", materialisedHomeservers);
+
var res = await Homeserver.ClientHttpClient.PostAsJsonAsync(fullJoinUrl, new {
reason
});
return await res.Content.ReadFromJsonAsync<RoomIdResponse>() ?? throw new Exception("Failed to join room?");
}
- public async IAsyncEnumerable<StateEventResponse> GetMembersEnumerableAsync(bool joinedOnly = true) {
- // var sw = Stopwatch.StartNew();
- var res = await Homeserver.ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{RoomId}/members");
- // if (sw.ElapsedMilliseconds > 1000)
- // Console.WriteLine($"Members call responded in {sw.GetElapsedAndRestart()}");
- // else sw.Restart();
- // var resText = await res.Content.ReadAsStringAsync();
- // Console.WriteLine($"Members call response read in {sw.GetElapsedAndRestart()}");
+ public async IAsyncEnumerable<StateEventResponse> GetMembersEnumerableAsync(string? membership = null) {
+ var url = $"/_matrix/client/v3/rooms/{RoomId}/members";
+ var isMembershipSet = !string.IsNullOrWhiteSpace(membership);
+ if (isMembershipSet) url += $"?membership={membership}";
+ var res = await Homeserver.ClientHttpClient.GetAsync(url);
var result = await JsonSerializer.DeserializeAsync<ChunkedStateEventResponse>(await res.Content.ReadAsStreamAsync(), new JsonSerializerOptions() {
TypeInfoResolver = ChunkedStateEventResponseSerializerContext.Default
});
- // if (sw.ElapsedMilliseconds > 100)
- // Console.WriteLine($"Members call deserialised in {sw.GetElapsedAndRestart()}");
- // else sw.Restart();
- foreach (var resp in result.Chunk) {
- if (resp?.Type != "m.room.member") continue;
- if (joinedOnly && resp.RawContent?["membership"]?.GetValue<string>() != "join") continue;
+
+ if (result is null) throw new Exception("Failed to deserialise members response");
+
+ foreach (var resp in result.Chunk ?? []) {
+ if (resp.Type != "m.room.member") continue;
+ if (isMembershipSet && resp.RawContent?["membership"]?.GetValue<string>() != membership) continue;
yield return resp;
}
-
- // if (sw.ElapsedMilliseconds > 100)
- // Console.WriteLine($"Members call iterated in {sw.GetElapsedAndRestart()}");
}
- public async Task<FrozenSet<StateEventResponse>> GetMembersListAsync(bool joinedOnly = true) {
- // var sw = Stopwatch.StartNew();
- var res = await Homeserver.ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{RoomId}/members");
- // if (sw.ElapsedMilliseconds > 1000)
- // Console.WriteLine($"Members call responded in {sw.GetElapsedAndRestart()}");
- // else sw.Restart();
- // var resText = await res.Content.ReadAsStringAsync();
- // Console.WriteLine($"Members call response read in {sw.GetElapsedAndRestart()}");
+ public async Task<FrozenSet<StateEventResponse>> GetMembersListAsync(string? membership = null) {
+ var url = $"/_matrix/client/v3/rooms/{RoomId}/members";
+ var isMembershipSet = !string.IsNullOrWhiteSpace(membership);
+ if (isMembershipSet) url += $"?membership={membership}";
+ var res = await Homeserver.ClientHttpClient.GetAsync(url);
var result = await JsonSerializer.DeserializeAsync<ChunkedStateEventResponse>(await res.Content.ReadAsStreamAsync(), new JsonSerializerOptions() {
TypeInfoResolver = ChunkedStateEventResponseSerializerContext.Default
});
- // if (sw.ElapsedMilliseconds > 100)
- // Console.WriteLine($"Members call deserialised in {sw.GetElapsedAndRestart()}");
- // else sw.Restart();
+
+ if (result is null) throw new Exception("Failed to deserialise members response");
+
var members = new List<StateEventResponse>();
- foreach (var resp in result.Chunk) {
- if (resp?.Type != "m.room.member") continue;
- if (joinedOnly && resp.RawContent?["membership"]?.GetValue<string>() != "join") continue;
+ foreach (var resp in result.Chunk ?? []) {
+ if (resp.Type != "m.room.member") continue;
+ if (isMembershipSet && resp.RawContent?["membership"]?.GetValue<string>() != membership) continue;
members.Add(resp);
}
- // if (sw.ElapsedMilliseconds > 100)
- // Console.WriteLine($"Members call iterated in {sw.GetElapsedAndRestart()}");
return members.ToFrozenSet();
}
+ public async IAsyncEnumerable<string> GetMemberIdsEnumerableAsync(string? membership = null) {
+ await foreach (var evt in GetMembersEnumerableAsync(membership))
+ yield return evt.StateKey!;
+ }
+
+ public async Task<FrozenSet<string>> GetMemberIdsListAsync(string? membership = null) {
+ var members = await GetMembersListAsync(membership);
+ return members.Select(x => x.StateKey!).ToFrozenSet();
+ }
+
#region Utility shortcuts
public Task<EventIdResponse> SendMessageEventAsync(RoomMessageEventContent content) =>
@@ -285,7 +288,7 @@ public class GenericRoom {
public async Task<List<string>?> GetAliasesAsync() {
var res = await GetStateAsync<RoomAliasEventContent>("m.room.aliases");
- return res.Aliases;
+ return res?.Aliases;
}
public Task<RoomCanonicalAliasEventContent?> GetCanonicalAliasAsync() =>
@@ -317,16 +320,17 @@ public class GenericRoom {
public Task<RoomPowerLevelEventContent?> GetPowerLevelsAsync() =>
GetStateAsync<RoomPowerLevelEventContent>("m.room.power_levels");
- [Obsolete("This method will be merged into GetNameAsync() in the future.")]
public async Task<string> GetNameOrFallbackAsync(int maxMemberNames = 2) {
try {
- return await GetNameAsync();
+ var name = await GetNameAsync();
+ if (!string.IsNullOrEmpty(name)) return name;
+ throw new();
}
catch {
try {
var alias = await GetCanonicalAliasAsync();
- if (alias?.Alias is not null) return alias.Alias;
- throw new Exception("No name or alias");
+ if (!string.IsNullOrWhiteSpace(alias?.Alias)) return alias.Alias;
+ throw new Exception("No alias");
}
catch {
try {
@@ -334,7 +338,8 @@ public class GenericRoom {
var memberList = new List<string>();
var memberCount = 0;
await foreach (var member in members)
- memberList.Add(member.RawContent?["displayname"]?.GetValue<string>() ?? "");
+ if (member.StateKey != Homeserver.UserId)
+ memberList.Add(member.RawContent?["displayname"]?.GetValue<string>() ?? "");
memberCount = memberList.Count;
memberList.RemoveAll(string.IsNullOrWhiteSpace);
memberList = memberList.OrderBy(x => x).ToList();
@@ -374,9 +379,9 @@ public class GenericRoom {
await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/ban",
new UserIdAndReason { UserId = userId, Reason = reason });
- public async Task UnbanAsync(string userId) =>
+ public async Task UnbanAsync(string userId, string? reason = null) =>
await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/unban",
- new UserIdAndReason { UserId = userId });
+ new UserIdAndReason { UserId = userId, Reason = reason });
public async Task InviteUserAsync(string userId, string? reason = null, bool skipExisting = true) {
if (skipExisting && await GetStateOrNullAsync<RoomMemberEventContent>("m.room.member", userId) is not null)
@@ -393,7 +398,7 @@ public class GenericRoom {
.Content.ReadFromJsonAsync<EventIdResponse>();
public async Task<EventIdResponse?> SendStateEventAsync(string eventType, string stateKey, object content) =>
- await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType}/{stateKey}", content))
+ await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType.UrlEncode()}/{stateKey.UrlEncode()}", content))
.Content.ReadFromJsonAsync<EventIdResponse>();
public async Task<EventIdResponse> SendTimelineEventAsync(string eventType, TimelineEventContent content) {
@@ -404,6 +409,15 @@ public class GenericRoom {
return await res.Content.ReadFromJsonAsync<EventIdResponse>() ?? throw new Exception("Failed to send event");
}
+ public async Task<EventIdResponse> SendReactionAsync(string eventId, string key) =>
+ await SendTimelineEventAsync("m.reaction", new RoomMessageReactionEventContent() {
+ RelatesTo = new() {
+ RelationType = "m.annotation",
+ EventId = eventId,
+ Key = key
+ }
+ });
+
public async Task<EventIdResponse?> SendFileAsync(string fileName, Stream fileStream, string messageType = "m.file", string contentType = "application/octet-stream") {
var url = await Homeserver.UploadFile(fileName, fileStream);
var content = new RoomMessageEventContent() {
@@ -429,6 +443,16 @@ public class GenericRoom {
return await res.Content.ReadFromJsonAsync<T>();
}
+ public async Task<T?> GetRoomAccountDataOrNullAsync<T>(string key) {
+ try {
+ return await GetRoomAccountDataAsync<T>(key);
+ }
+ catch (MatrixException e) {
+ if (e.ErrorCode == "M_NOT_FOUND") return default;
+ throw;
+ }
+ }
+
public async Task SetRoomAccountDataAsync(string key, object data) {
var res = await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/user/{Homeserver.UserId}/rooms/{RoomId}/account_data/{key}", data);
if (!res.IsSuccessStatusCode) {
@@ -437,13 +461,65 @@ public class GenericRoom {
}
}
- public Task<StateEventResponse> GetEventAsync(string eventId) =>
- Homeserver.ClientHttpClient.GetFromJsonAsync<StateEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/event/{eventId}");
+ public Task<StateEventResponse> GetEventAsync(string eventId, bool includeUnredactedContent = false) =>
+ Homeserver.ClientHttpClient.GetFromJsonAsync<StateEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/event/{eventId}?fi.mau.msc2815.include_unredacted_content={includeUnredactedContent}");
- public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string reason) {
+ public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string? reason = null) {
var data = new { reason };
- return (await (await Homeserver.ClientHttpClient.PutAsJsonAsync(
- $"/_matrix/client/v3/rooms/{RoomId}/redact/{eventToRedact}/{Guid.NewGuid()}", data)).Content.ReadFromJsonAsync<EventIdResponse>())!;
+ var url = $"/_matrix/client/v3/rooms/{RoomId}/redact/{eventToRedact}/{Guid.NewGuid().ToString()}";
+ while (true) {
+ try {
+ return (await (await Homeserver.ClientHttpClient.PutAsJsonAsync(url, data)).Content.ReadFromJsonAsync<EventIdResponse>())!;
+ }
+ catch (MatrixException e) {
+ if (e is { ErrorCode: MatrixException.ErrorCodes.M_FORBIDDEN }) throw;
+ throw;
+ }
+ }
+ }
+
+#endregion
+
+#region Ephemeral Events
+
+ /// <summary>
+ /// This tells the server that the user is typing for the next N milliseconds where
+ /// N is the value specified in the timeout key. Alternatively, if typing is false,
+ /// it tells the server that the user has stopped typing.
+ /// </summary>
+ /// <param name="typing">Whether the user is typing or not.</param>
+ /// <param name="timeout">The length of time in milliseconds to mark this user as typing.</param>
+ public async Task SendTypingNotificationAsync(bool typing, int timeout = 30000) {
+ await Homeserver.ClientHttpClient.PutAsJsonAsync(
+ $"/_matrix/client/v3/rooms/{RoomId}/typing/{Homeserver.UserId}", new JsonObject {
+ ["timeout"] = typing ? timeout : null,
+ ["typing"] = typing
+ }, new JsonSerializerOptions {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ });
+ }
+
+ /// <summary>
+ /// Updates the marker for the given receipt type to the event ID specified.
+ /// </summary>
+ /// <param name="eventId">The event ID to acknowledge up to.</param>
+ /// <param name="threadId">
+ /// The root thread event’s ID (or main) for which thread this receipt is intended to be under.
+ /// If not specified, the read receipt is unthreaded (default).
+ /// </param>
+ /// <param name="isPrivate">
+ /// If set to true, a receipt type of m.read.private is sent instead of m.read, which marks the
+ /// room as "read" only for the current user
+ /// </param>
+ public async Task SendReadReceiptAsync(string eventId, string? threadId = null, bool isPrivate = false) {
+ var request = new JsonObject();
+ if (threadId != null)
+ request.Add("thread_id", threadId);
+ await Homeserver.ClientHttpClient.PostAsJsonAsync(
+ $"/_matrix/client/v3/rooms/{RoomId}/receipt/m.read{(isPrivate ? ".private" : "")}/{eventId}", request,
+ new JsonSerializerOptions {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ });
}
#endregion
@@ -510,7 +586,7 @@ public class GenericRoom {
var uri = new Uri(path, UriKind.Relative);
if (dir == "b" || dir == "f") uri = uri.AddQuery("dir", dir);
- else if(!string.IsNullOrWhiteSpace(dir)) throw new ArgumentException("Invalid direction", nameof(dir));
+ else if (!string.IsNullOrWhiteSpace(dir)) throw new ArgumentException("Invalid direction", nameof(dir));
if (!string.IsNullOrEmpty(from)) uri = uri.AddQuery("from", from);
if (chunkLimit is not null) uri = uri.AddQuery("limit", chunkLimit.Value.ToString());
if (recurse is not null) uri = uri.AddQuery("recurse", recurse.Value.ToString());
@@ -528,10 +604,11 @@ public class GenericRoom {
}
}
- public readonly SpaceRoom AsSpace;
+ public SpaceRoom AsSpace() => new SpaceRoom(Homeserver, RoomId);
+ public PolicyRoom AsPolicyRoom() => new PolicyRoom(Homeserver, RoomId);
}
public class RoomIdResponse {
[JsonPropertyName("room_id")]
- public string RoomId { get; set; } = null!;
+ public string RoomId { get; set; }
}
\ No newline at end of file
diff --git a/LibMatrix/RoomTypes/PolicyRoom.cs b/LibMatrix/RoomTypes/PolicyRoom.cs
new file mode 100644
index 0000000..c6eec63
--- /dev/null
+++ b/LibMatrix/RoomTypes/PolicyRoom.cs
@@ -0,0 +1,51 @@
+using System.Collections.Frozen;
+using LibMatrix.EventTypes;
+using LibMatrix.EventTypes.Spec.State.Policy;
+using LibMatrix.Homeservers;
+
+namespace LibMatrix.RoomTypes;
+
+public class PolicyRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) : GenericRoom(homeserver, roomId) {
+ public const string TypeName = "support.feline.policy.lists.msc.v1";
+
+ public static readonly FrozenSet<string> UserPolicyEventTypes = EventContent.GetMatchingEventTypes<UserPolicyRuleEventContent>().ToFrozenSet();
+ public static readonly FrozenSet<string> ServerPolicyEventTypes = EventContent.GetMatchingEventTypes<ServerPolicyRuleEventContent>().ToFrozenSet();
+ public static readonly FrozenSet<string> RoomPolicyEventTypes = EventContent.GetMatchingEventTypes<RoomPolicyRuleEventContent>().ToFrozenSet();
+ public static readonly FrozenSet<string> SpecPolicyEventTypes = [..UserPolicyEventTypes, ..ServerPolicyEventTypes, ..RoomPolicyEventTypes];
+
+ public async IAsyncEnumerable<StateEventResponse> GetPoliciesAsync() {
+ var fullRoomState = GetFullStateAsync();
+ await foreach (var eventResponse in fullRoomState) {
+ if (SpecPolicyEventTypes.Contains(eventResponse!.Type)) {
+ yield return eventResponse;
+ }
+ }
+ }
+
+ public async IAsyncEnumerable<StateEventResponse> GetUserPoliciesAsync() {
+ var fullRoomState = GetPoliciesAsync();
+ await foreach (var eventResponse in fullRoomState) {
+ if (UserPolicyEventTypes.Contains(eventResponse!.Type)) {
+ yield return eventResponse;
+ }
+ }
+ }
+
+ public async IAsyncEnumerable<StateEventResponse> GetServerPoliciesAsync() {
+ var fullRoomState = GetPoliciesAsync();
+ await foreach (var eventResponse in fullRoomState) {
+ if (ServerPolicyEventTypes.Contains(eventResponse!.Type)) {
+ yield return eventResponse;
+ }
+ }
+ }
+
+ public async IAsyncEnumerable<StateEventResponse> GetRoomPoliciesAsync() {
+ var fullRoomState = GetPoliciesAsync();
+ await foreach (var eventResponse in fullRoomState) {
+ if (RoomPolicyEventTypes.Contains(eventResponse!.Type)) {
+ yield return eventResponse;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/RoomTypes/SpaceRoom.cs b/LibMatrix/RoomTypes/SpaceRoom.cs
index b40ccc6..96abd77 100644
--- a/LibMatrix/RoomTypes/SpaceRoom.cs
+++ b/LibMatrix/RoomTypes/SpaceRoom.cs
@@ -1,9 +1,12 @@
using ArcaneLibs.Extensions;
using LibMatrix.Homeservers;
+using LibMatrix.Responses;
namespace LibMatrix.RoomTypes;
public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) : GenericRoom(homeserver, roomId) {
+ public const string TypeName = "m.space";
+
public async IAsyncEnumerable<GenericRoom> GetChildrenAsync(bool includeRemoved = false) {
// var rooms = new List<GenericRoom>();
var state = GetFullStateAsync();
@@ -15,7 +18,7 @@ public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId)
}
public async Task<EventIdResponse> AddChildAsync(GenericRoom room) {
- var members = room.GetMembersEnumerableAsync(true);
+ var members = room.GetMembersEnumerableAsync("join");
Dictionary<string, int> memberCountByHs = new();
await foreach (var member in members) {
var server = member.StateKey.Split(':')[1];
@@ -31,7 +34,7 @@ public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId)
});
return resp;
}
-
+
public async Task<EventIdResponse> AddChildByIdAsync(string id) {
return await AddChildAsync(Homeserver.GetRoom(id));
}
diff --git a/LibMatrix/Services/HomeserverProviderService.cs b/LibMatrix/Services/HomeserverProviderService.cs
index 3e42c21..36bc828 100644
--- a/LibMatrix/Services/HomeserverProviderService.cs
+++ b/LibMatrix/Services/HomeserverProviderService.cs
@@ -1,6 +1,5 @@
using System.Net.Http.Json;
using ArcaneLibs.Collections;
-using ArcaneLibs.Extensions;
using LibMatrix.Homeservers;
using LibMatrix.Responses;
using Microsoft.Extensions.Logging;
@@ -8,9 +7,9 @@ using Microsoft.Extensions.Logging;
namespace LibMatrix.Services;
public class HomeserverProviderService(ILogger<HomeserverProviderService> logger, HomeserverResolverService hsResolver) {
- private static SemaphoreCache<AuthenticatedHomeserverGeneric> AuthenticatedHomeserverCache = new();
- private static SemaphoreCache<RemoteHomeserver> RemoteHomeserverCache = new();
- private static SemaphoreCache<FederationClient> FederationClientCache = new();
+ private static readonly SemaphoreCache<AuthenticatedHomeserverGeneric> AuthenticatedHomeserverCache = new();
+ private static readonly SemaphoreCache<RemoteHomeserver> RemoteHomeserverCache = new();
+ private static readonly SemaphoreCache<FederationClient> FederationClientCache = new();
public async Task<AuthenticatedHomeserverGeneric> GetAuthenticatedWithToken(string homeserver, string accessToken, string? proxy = null, string? impersonatedMxid = null,
bool useGeneric = false, bool enableClient = true, bool enableServer = true) {
@@ -23,9 +22,11 @@ public class HomeserverProviderService(ILogger<HomeserverProviderService> logger
AuthenticatedHomeserverGeneric? hs = null;
if (!useGeneric) {
- ClientVersionsResponse? clientVersions = new();
+ var clientVersionsTask = rhs.GetClientVersionsAsync();
+ var serverVersionTask = rhs.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null)!;
+ ClientVersionsResponse clientVersions = new();
try {
- clientVersions = await rhs.GetClientVersionsAsync();
+ clientVersions = await clientVersionsTask;
}
catch (Exception e) {
logger.LogError(e, "Failed to get client versions for {homeserver}", homeserver);
@@ -33,7 +34,7 @@ public class HomeserverProviderService(ILogger<HomeserverProviderService> logger
ServerVersionResponse? serverVersion;
try {
- serverVersion = await (rhs.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null)!);
+ serverVersion = await serverVersionTask;
}
catch (Exception e) {
logger.LogWarning(e, "Failed to get server version for {homeserver}", homeserver);
@@ -46,6 +47,8 @@ public class HomeserverProviderService(ILogger<HomeserverProviderService> logger
else {
if (serverVersion is { Server.Name: "Synapse" })
hs = new AuthenticatedHomeserverSynapse(homeserver, wellKnownUris, proxy, accessToken);
+ else if (serverVersion is { Server.Name: "LibMatrix.HomeserverEmulator"})
+ hs = new AuthenticatedHomeserverHSE(homeserver, wellKnownUris, proxy, accessToken);
}
}
catch (Exception e) {
@@ -65,7 +68,7 @@ public class HomeserverProviderService(ILogger<HomeserverProviderService> logger
});
}
- public async Task<RemoteHomeserver> GetRemoteHomeserver(string homeserver, string? proxy = null, bool useCache = true, bool enableServer = true) =>
+ public async Task<RemoteHomeserver> GetRemoteHomeserver(string homeserver, string? proxy = null, bool useCache = true, bool enableServer = false) =>
useCache
? await RemoteHomeserverCache.GetOrAdd($"{homeserver}{proxy}",
async () => { return new RemoteHomeserver(homeserver, await hsResolver.ResolveHomeserverFromWellKnown(homeserver, enableServer: enableServer), proxy); })
diff --git a/LibMatrix/Services/HomeserverResolverService.cs b/LibMatrix/Services/HomeserverResolverService.cs
index f899230..94a3826 100644
--- a/LibMatrix/Services/HomeserverResolverService.cs
+++ b/LibMatrix/Services/HomeserverResolverService.cs
@@ -1,8 +1,4 @@
-using System.Collections.Concurrent;
using System.Diagnostics;
-using System.Net.Http.Json;
-using System.Text.Json;
-using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ArcaneLibs.Collections;
using ArcaneLibs.Extensions;
@@ -13,9 +9,7 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace LibMatrix.Services;
public class HomeserverResolverService {
- private readonly MatrixHttpClient _httpClient = new() {
- // Timeout = TimeSpan.FromSeconds(60) // TODO: Re-implement this
- };
+ private readonly MatrixHttpClient _httpClient = new();
private static readonly SemaphoreCache<WellKnownUris> WellKnownCache = new();
@@ -26,68 +20,67 @@ public class HomeserverResolverService {
if (logger is NullLogger<HomeserverResolverService>) {
var stackFrame = new StackTrace(true).GetFrame(1);
Console.WriteLine(
- $"WARN | Null logger provided to HomeserverResolverService!\n{stackFrame.GetMethod().DeclaringType} at {stackFrame.GetFileName()}:{stackFrame.GetFileLineNumber()}");
+ $"WARN | Null logger provided to HomeserverResolverService!\n{stackFrame?.GetMethod()?.DeclaringType?.ToString() ?? "null"} at {stackFrame?.GetFileName() ?? "null"}:{stackFrame?.GetFileLineNumber().ToString() ?? "null"}");
}
}
- // private static SemaphoreSlim _wellKnownSemaphore = new(1, 1);
-
public async Task<WellKnownUris> ResolveHomeserverFromWellKnown(string homeserver, bool enableClient = true, bool enableServer = true) {
ArgumentNullException.ThrowIfNull(homeserver);
return await WellKnownCache.GetOrAdd(homeserver, async () => {
- // await _wellKnownSemaphore.WaitAsync();
_logger.LogTrace($"Resolving homeserver well-knowns: {homeserver}");
var client = enableClient ? _tryResolveClientEndpoint(homeserver) : null;
var server = enableServer ? _tryResolveServerEndpoint(homeserver) : null;
var res = new WellKnownUris();
- // try {
if (client != null)
- res.Client = await client ?? throw new Exception($"Could not resolve client URL for {homeserver}.");
- // }
- // catch (Exception e) {
- // _logger.LogError(e, "Error resolving client well-known for {hs}", homeserver);
- // }
+ res.Client = (await client)?.TrimEnd('/') ?? throw new Exception($"Could not resolve client URL for {homeserver}.");
- // try {
if (server != null)
- res.Server = await server ?? throw new Exception($"Could not resolve server URL for {homeserver}.");
- // }
- // catch (Exception e) {
- // _logger.LogError(e, "Error resolving server well-known for {hs}", homeserver);
- // }
+ res.Server = (await server)?.TrimEnd('/') ?? throw new Exception($"Could not resolve server URL for {homeserver}.");
_logger.LogInformation("Resolved well-knowns for {hs}: {json}", homeserver, res.ToJson(indent: false));
- // _wellKnownSemaphore.Release();
return res;
});
}
-
- // private async Task<WellKnownUris> InternalResolveHomeserverFromWellKnown(string homeserver) {
-
- // }
-
+
+ private async Task<T?> GetFromJsonAsync<T>(string url) {
+ try {
+ return await _httpClient.GetFromJsonAsync<T>(url);
+ }
+ catch (Exception e) {
+ _logger.LogWarning(e, "Failed to get JSON from {url}", url);
+ return default;
+ }
+ }
+
private async Task<string?> _tryResolveClientEndpoint(string homeserver) {
ArgumentNullException.ThrowIfNull(homeserver);
_logger.LogTrace("Resolving client well-known: {homeserver}", homeserver);
ClientWellKnown? clientWellKnown = null;
+ homeserver = homeserver.TrimEnd('/');
// check if homeserver has a client well-known
if (homeserver.StartsWith("https://")) {
- clientWellKnown = await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client");
+ clientWellKnown = await GetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client");
+
+ if (clientWellKnown is null && await MatrixHttpClient.CheckSuccessStatus($"{homeserver}/_matrix/client/versions"))
+ return homeserver;
}
else if (homeserver.StartsWith("http://")) {
- clientWellKnown = await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client");
+ clientWellKnown = await GetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client");
+
+ if (clientWellKnown is null && await MatrixHttpClient.CheckSuccessStatus($"{homeserver}/_matrix/client/versions"))
+ return homeserver;
}
else {
- clientWellKnown ??= await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"https://{homeserver}/.well-known/matrix/client");
- clientWellKnown ??= await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"http://{homeserver}/.well-known/matrix/client");
+ clientWellKnown ??= await GetFromJsonAsync<ClientWellKnown>($"https://{homeserver}/.well-known/matrix/client");
+ clientWellKnown ??= await GetFromJsonAsync<ClientWellKnown>($"http://{homeserver}/.well-known/matrix/client");
if (clientWellKnown is null) {
- if (await _httpClient.CheckSuccessStatus($"https://{homeserver}/_matrix/client/versions"))
+ if (await MatrixHttpClient.CheckSuccessStatus($"https://{homeserver}/_matrix/client/versions"))
return $"https://{homeserver}";
- if (await _httpClient.CheckSuccessStatus($"http://{homeserver}/_matrix/client/versions"))
+ if (await MatrixHttpClient.CheckSuccessStatus($"http://{homeserver}/_matrix/client/versions"))
return $"http://{homeserver}";
}
}
@@ -104,40 +97,42 @@ public class HomeserverResolverService {
ArgumentNullException.ThrowIfNull(homeserver);
_logger.LogTrace($"Resolving server well-known: {homeserver}");
ServerWellKnown? serverWellKnown = null;
+ homeserver = homeserver.TrimEnd('/');
// check if homeserver has a server well-known
if (homeserver.StartsWith("https://")) {
- serverWellKnown = await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server");
+ serverWellKnown = await GetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server");
}
else if (homeserver.StartsWith("http://")) {
- serverWellKnown = await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server");
+ serverWellKnown = await GetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server");
}
else {
- serverWellKnown ??= await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"https://{homeserver}/.well-known/matrix/server");
- serverWellKnown ??= await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"http://{homeserver}/.well-known/matrix/server");
+ serverWellKnown ??= await GetFromJsonAsync<ServerWellKnown>($"https://{homeserver}/.well-known/matrix/server");
+ serverWellKnown ??= await GetFromJsonAsync<ServerWellKnown>($"http://{homeserver}/.well-known/matrix/server");
}
_logger.LogInformation("Server well-known for {hs}: {json}", homeserver, serverWellKnown?.ToJson() ?? "null");
if (!string.IsNullOrWhiteSpace(serverWellKnown?.Homeserver)) {
- var resolved = serverWellKnown.Homeserver;
+ var resolved = serverWellKnown.Homeserver.TrimEnd('/');
if (resolved.StartsWith("https://") || resolved.StartsWith("http://"))
return resolved;
- if (await _httpClient.CheckSuccessStatus($"https://{resolved}/_matrix/federation/v1/version"))
+ if (await MatrixHttpClient.CheckSuccessStatus($"https://{resolved}/_matrix/federation/v1/version"))
return $"https://{resolved}";
- if (await _httpClient.CheckSuccessStatus($"http://{resolved}/_matrix/federation/v1/version"))
+ if (await MatrixHttpClient.CheckSuccessStatus($"http://{resolved}/_matrix/federation/v1/version"))
return $"http://{resolved}";
_logger.LogWarning("Server well-known points to invalid server: {resolved}", resolved);
}
// fallback: most servers host C2S and S2S on the same domain
- var clientUrl = await _tryResolveClientEndpoint(homeserver);
- if (clientUrl is not null && await _httpClient.CheckSuccessStatus($"{clientUrl}/_matrix/federation/v1/version"))
+ var clientUrl = (await _tryResolveClientEndpoint(homeserver)).TrimEnd('/');
+ if (clientUrl is not null && await MatrixHttpClient.CheckSuccessStatus($"{clientUrl}/_matrix/federation/v1/version"))
return clientUrl;
_logger.LogInformation("No server well-known for {server}...", homeserver);
return null;
}
-
+
+ [Obsolete("Use authenticated media, available on AuthenticatedHomeserverGeneric", true)]
public async Task<string?> ResolveMediaUri(string homeserver, string mxc) {
if (homeserver is null) throw new ArgumentNullException(nameof(homeserver));
if (mxc is null) throw new ArgumentNullException(nameof(mxc));
diff --git a/LibMatrix/Services/ServiceInstaller.cs b/LibMatrix/Services/ServiceInstaller.cs
index 06ea9de..5ffd43a 100644
--- a/LibMatrix/Services/ServiceInstaller.cs
+++ b/LibMatrix/Services/ServiceInstaller.cs
@@ -1,27 +1,25 @@
+using LibMatrix.Services.WellKnownResolver;
+using LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
namespace LibMatrix.Services;
public static class ServiceInstaller {
public static IServiceCollection AddRoryLibMatrixServices(this IServiceCollection services, RoryLibMatrixConfiguration? config = null) {
- //Check required services
- // if (!services.Any(x => x.ServiceType == typeof(TieredStorageService)))
- // throw new Exception("[RMUCore/DI] No TieredStorageService has been registered!");
//Add config
services.AddSingleton(config ?? new RoryLibMatrixConfiguration());
//Add services
- services.AddSingleton<HomeserverResolverService>(sp => new HomeserverResolverService(sp.GetRequiredService<ILogger<HomeserverResolverService>>()));
-
- // if (services.First(x => x.ServiceType == typeof(TieredStorageService)).Lifetime == ServiceLifetime.Singleton) {
+ services.AddSingleton<ClientWellKnownResolver>();
+ services.AddSingleton<ServerWellKnownResolver>();
+ services.AddSingleton<SupportWellKnownResolver>();
+ if (!services.Any(x => x.ServiceType == typeof(WellKnownResolverConfiguration)))
+ services.AddSingleton<WellKnownResolverConfiguration>();
+ services.AddSingleton<WellKnownResolverService>();
+ // Legacy
+ services.AddSingleton<HomeserverResolverService>();
services.AddSingleton<HomeserverProviderService>();
- // }
- // else {
- // services.AddScoped<HomeserverProviderService>();
- // }
- // services.AddScoped<MatrixHttpClient>();
return services;
}
}
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs
new file mode 100644
index 0000000..26a4c43
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs
@@ -0,0 +1,49 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Services.WellKnownResolver;
+
+public class WellKnownResolverConfiguration {
+ /// <summary>
+ /// Allow transparent downgrades to plaintext HTTP if HTTPS fails
+ /// Enabling this is unsafe!
+ /// </summary>
+ [JsonPropertyName("allow_http")]
+ public bool AllowHttp { get; set; } = false;
+
+ /// <summary>
+ /// Use DNS resolution if available, for resolving SRV records
+ /// </summary>
+ [JsonPropertyName("allow_dns")]
+ public bool AllowDns { get; set; } = true;
+
+ /// <summary>
+ /// Use system resolver(s) if empty
+ /// </summary>
+ [JsonPropertyName("dns_servers")]
+ public List<string> DnsServers { get; set; } = new();
+
+ /// <summary>
+ /// Same as AllowDns, but for DNS over HTTPS - useful in browser contexts
+ /// </summary>
+ [JsonPropertyName("allow_doh")]
+ public bool AllowDoh { get; set; } = true;
+
+ /// <summary>
+ /// Use DNS over HTTPS - useful in browser contexts
+ /// Disabled if empty
+ /// </summary>
+ [JsonPropertyName("doh_servers")]
+ public List<string> DohServers { get; set; } = new();
+
+ /// <summary>
+ /// Whether to allow fallback subdomain lookups
+ /// </summary>
+ [JsonPropertyName("allow_fallback_subdomains")]
+ public bool AllowFallbackSubdomains { get; set; } = true;
+
+ /// <summary>
+ /// Fallback subdomains to try if the homeserver is not found
+ /// </summary>
+ [JsonPropertyName("fallback_subdomains")]
+ public List<string> FallbackSubdomains { get; set; } = ["matrix", "chat", "im"];
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs
new file mode 100644
index 0000000..4c78347
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs
@@ -0,0 +1,91 @@
+using System.Diagnostics;
+using System.Text.Json.Serialization;
+using LibMatrix.Extensions;
+using LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace LibMatrix.Services.WellKnownResolver;
+
+public class WellKnownResolverService {
+ private readonly MatrixHttpClient _httpClient = new();
+
+ private readonly ILogger<WellKnownResolverService> _logger;
+ private readonly ClientWellKnownResolver _clientWellKnownResolver;
+ private readonly SupportWellKnownResolver _supportWellKnownResolver;
+ private readonly ServerWellKnownResolver _serverWellKnownResolver;
+ private readonly WellKnownResolverConfiguration _configuration;
+
+ public WellKnownResolverService(ILogger<WellKnownResolverService> logger, ClientWellKnownResolver clientWellKnownResolver, SupportWellKnownResolver supportWellKnownResolver,
+ WellKnownResolverConfiguration configuration, ServerWellKnownResolver serverWellKnownResolver) {
+ _logger = logger;
+ _clientWellKnownResolver = clientWellKnownResolver;
+ _supportWellKnownResolver = supportWellKnownResolver;
+ _configuration = configuration;
+ _serverWellKnownResolver = serverWellKnownResolver;
+ if (logger is NullLogger<WellKnownResolverService>) {
+ var stackFrame = new StackTrace(true).GetFrame(1);
+ Console.WriteLine(
+ $"WARN | Null logger provided to WellKnownResolverService!\n{stackFrame?.GetMethod()?.DeclaringType?.ToString() ?? "null"} at {stackFrame?.GetFileName() ?? "null"}:{stackFrame?.GetFileLineNumber().ToString() ?? "null"}");
+ }
+ }
+
+ public async Task<WellKnownRecords> TryResolveWellKnownRecords(string homeserver, bool includeClient = true, bool includeServer = true, bool includeSupport = true,
+ WellKnownResolverConfiguration? config = null) {
+ WellKnownRecords records = new();
+ _logger.LogDebug($"Resolving well-knowns for {homeserver}");
+ if (includeClient && await _clientWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } clientResult) {
+ records.ClientWellKnown = clientResult;
+ }
+
+ if (includeServer && await _serverWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } serverResult) {
+ records.ServerWellKnown = serverResult;
+ }
+
+ if (includeSupport && await _supportWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } supportResult) {
+ records.SupportWellKnown = supportResult;
+ }
+
+ return records;
+ }
+
+ public class WellKnownRecords {
+ public WellKnownResolutionResult<ClientWellKnown?>? ClientWellKnown { get; set; }
+ public WellKnownResolutionResult<ServerWellKnown?>? ServerWellKnown { get; set; }
+ public WellKnownResolutionResult<SupportWellKnown?>? SupportWellKnown { get; set; }
+ }
+
+ public class WellKnownResolutionResult<T> {
+ public WellKnownSource Source { get; set; }
+ public string? SourceUri { get; set; }
+ public T? Content { get; set; }
+ public List<WellKnownResolutionWarning> Warnings { get; set; } = [];
+ }
+
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum WellKnownSource {
+ None,
+ Https,
+ Dns,
+ Http,
+ ManualCheck,
+ Search
+ }
+
+ public struct WellKnownResolutionWarning {
+ public WellKnownResolutionWarningType Type { get; set; }
+ public string Message { get; set; }
+ [JsonIgnore]
+ public Exception? Exception { get; set; }
+ public string? ExceptionMessage => Exception?.Message;
+
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum WellKnownResolutionWarningType {
+ None,
+ Exception,
+ InvalidResponse,
+ Timeout,
+ SlowResponse
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs
new file mode 100644
index 0000000..cbe5b0a
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs
@@ -0,0 +1,52 @@
+using System.Diagnostics;
+using System.Net.Http.Json;
+using ArcaneLibs.Collections;
+using LibMatrix.Extensions;
+
+namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+
+public class BaseWellKnownResolver<T> where T : class, new() {
+ internal static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<T>> WellKnownCache = new() {
+ StoreNulls = false
+ };
+
+ internal static readonly MatrixHttpClient HttpClient = new();
+
+ internal async Task<WellKnownResolverService.WellKnownResolutionResult<T>> TryGetWellKnownFromUrl(string url,
+ WellKnownResolverService.WellKnownSource source) {
+ var sw = Stopwatch.StartNew();
+ try {
+ var request = await HttpClient.GetAsync(url);
+ sw.Stop();
+ var result = new WellKnownResolverService.WellKnownResolutionResult<T> {
+ Content = await request.Content.ReadFromJsonAsync<T>(),
+ Source = source,
+ SourceUri = url,
+ Warnings = []
+ };
+
+ if (sw.ElapsedMilliseconds > 1000) {
+ // logger.LogWarning($"Support well-known resolution took {sw.ElapsedMilliseconds}ms: {url}");
+ result.Warnings.Add(new() {
+ Type = WellKnownResolverService.WellKnownResolutionWarning.WellKnownResolutionWarningType.SlowResponse,
+ Message = $"Well-known resolution took {sw.ElapsedMilliseconds}ms"
+ });
+ }
+
+ return result;
+ }
+ catch (Exception e) {
+ return new WellKnownResolverService.WellKnownResolutionResult<T> {
+ Source = source,
+ SourceUri = url,
+ Warnings = [
+ new() {
+ Exception = e,
+ Type = WellKnownResolverService.WellKnownResolutionWarning.WellKnownResolutionWarningType.Exception,
+ Message = e.Message
+ }
+ ]
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs
new file mode 100644
index 0000000..f8de38d
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs
@@ -0,0 +1,42 @@
+using System.Text.Json.Serialization;
+using ArcaneLibs.Collections;
+using LibMatrix.Extensions;
+using Microsoft.Extensions.Logging;
+using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ClientWellKnown;
+using ResultType =
+ LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult<LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ClientWellKnown?>;
+
+namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+
+public class ClientWellKnownResolver(ILogger<ClientWellKnownResolver> logger, WellKnownResolverConfiguration configuration)
+ : BaseWellKnownResolver<ClientWellKnown> {
+ private static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown>> ClientWellKnownCache = new() {
+ StoreNulls = false
+ };
+
+ private static readonly MatrixHttpClient HttpClient = new();
+
+ public Task<WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown>> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) {
+ config ??= configuration;
+ return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => {
+ logger.LogTrace($"Resolving client well-known: {homeserver}");
+
+ WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown> result =
+ await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/client", WellKnownResolverService.WellKnownSource.Https);
+ if (result.Content != null) return result;
+
+
+ return result;
+ });
+ }
+}
+
+public class ClientWellKnown {
+ [JsonPropertyName("m.homeserver")]
+ public WellKnownHomeserver Homeserver { get; set; }
+
+ public class WellKnownHomeserver {
+ [JsonPropertyName("base_url")]
+ public required string BaseUrl { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs
new file mode 100644
index 0000000..a99185c
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+using ArcaneLibs.Collections;
+using LibMatrix.Extensions;
+using Microsoft.Extensions.Logging;
+using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ServerWellKnown;
+using ResultType =
+ LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult<LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ServerWellKnown?>;
+
+namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+
+public class ServerWellKnownResolver(ILogger<ServerWellKnownResolver> logger, WellKnownResolverConfiguration configuration)
+ : BaseWellKnownResolver<ServerWellKnown> {
+ private static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown>> ClientWellKnownCache = new() {
+ StoreNulls = false
+ };
+
+ private static readonly MatrixHttpClient HttpClient = new();
+
+ public Task<WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown>> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) {
+ config ??= configuration;
+ return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => {
+ logger.LogTrace($"Resolving client well-known: {homeserver}");
+
+ WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown> result =
+ await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/server", WellKnownResolverService.WellKnownSource.Https);
+ if (result.Content != null) return result;
+
+
+ return result;
+ });
+ }
+}
+
+
+public class ServerWellKnown {
+ [JsonPropertyName("m.server")]
+ public string Homeserver { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs
new file mode 100644
index 0000000..99313db
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs
@@ -0,0 +1,44 @@
+using System.Diagnostics;
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.SupportWellKnown;
+using ResultType = LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult<
+ LibMatrix.Services.WellKnownResolver.WellKnownResolvers.SupportWellKnown?
+>;
+
+namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+
+public class SupportWellKnownResolver(ILogger<SupportWellKnownResolver> logger, WellKnownResolverConfiguration configuration) : BaseWellKnownResolver<WellKnownType> {
+ public Task<ResultType> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) {
+ config ??= configuration;
+ return WellKnownCache.TryGetOrAdd(homeserver, async () => {
+ logger.LogTrace($"Resolving support well-known: {homeserver}");
+
+ ResultType result = await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/support", WellKnownResolverService.WellKnownSource.Https);
+ if (result.Content != null)
+ return result;
+
+ return null;
+ });
+ }
+}
+
+public class SupportWellKnown {
+ [JsonPropertyName("contacts")]
+ public List<WellKnownContact>? Contacts { get; set; }
+
+ [JsonPropertyName("support_page")]
+ public Uri? SupportPage { get; set; }
+
+ public class WellKnownContact {
+ [JsonPropertyName("email_address")]
+ public string? EmailAddress { get; set; }
+
+ [JsonPropertyName("matrix_id")]
+ public string? MatrixId { get; set; }
+
+ [JsonPropertyName("role")]
+ public required string Role { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs
index 81ee3fe..af25805 100644
--- a/LibMatrix/StateEvent.cs
+++ b/LibMatrix/StateEvent.cs
@@ -13,7 +13,7 @@ using LibMatrix.Extensions;
namespace LibMatrix;
public class StateEvent {
- public static FrozenSet<Type> KnownStateEventTypes { get; } = new ClassCollector<EventContent>().ResolveFromAllAccessibleAssemblies().ToFrozenSet();
+ public static FrozenSet<Type> KnownStateEventTypes { get; } = ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies().ToFrozenSet();
public static FrozenDictionary<string, Type> KnownStateEventTypesByName { get; } = KnownStateEventTypes.Aggregate(
new Dictionary<string, Type>(),
@@ -44,6 +44,7 @@ public class StateEvent {
public string FriendlyTypeNamePlural => MappedType.GetFriendlyNamePluralOrNull() ?? Type;
private static readonly JsonSerializerOptions TypedContentSerializerOptions = new() {
+ // We need these, NumberHandling covers other number types that we don't want to convert
Converters = {
new JsonFloatStringConverter(),
new JsonDoubleStringConverter(),
@@ -55,9 +56,6 @@ public class StateEvent {
[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")]
public EventContent? TypedContent {
get {
- // if (Type == "m.receipt") {
- // return null;
- // }
try {
var mappedType = GetStateEventType(Type);
if (mappedType == typeof(UnknownEventContent))
@@ -81,6 +79,18 @@ public class StateEvent {
}
}
+ public T? ContentAs<T>() {
+ try {
+ return RawContent.Deserialize<T>(TypedContentSerializerOptions)!;
+ }
+ catch (JsonException e) {
+ Console.WriteLine(e);
+ Console.WriteLine("Content:\n" + (RawContent?.ToJson() ?? "null"));
+ }
+
+ return default;
+ }
+
[JsonPropertyName("state_key")]
public string? StateKey { get; set; }
@@ -90,44 +100,10 @@ public class StateEvent {
[JsonPropertyName("replaces_state")]
public string? ReplacesState { get; set; }
- private JsonObject? _rawContent;
-
[JsonPropertyName("content")]
- public JsonObject? RawContent {
- get => _rawContent;
- set => _rawContent = value;
- }
- //
- // [JsonIgnore]
- // public new Type GetType {
- // get {
- // var type = GetStateEventType(Type);
- //
- // //special handling for some types
- // // if (type == typeof(RoomEmotesEventContent)) {
- // // RawContent["emote"] = RawContent["emote"]?.AsObject() ?? new JsonObject();
- // // }
- // //
- // // if (this is StateEventResponse stateEventResponse) {
- // // if (type == null || type == typeof(object)) {
- // // Console.WriteLine($"Warning: unknown event type '{Type}'!");
- // // Console.WriteLine(RawContent.ToJson());
- // // Directory.CreateDirectory($"unknown_state_events/{Type}");
- // // File.WriteAllText($"unknown_state_events/{Type}/{stateEventResponse.EventId}.json",
- // // RawContent.ToJson());
- // // Console.WriteLine($"Saved to unknown_state_events/{Type}/{stateEventResponse.EventId}.json");
- // // }
- // // else if (RawContent is not null && RawContent.FindExtraJsonObjectFields(type)) {
- // // Directory.CreateDirectory($"unknown_state_events/{Type}");
- // // File.WriteAllText($"unknown_state_events/{Type}/{stateEventResponse.EventId}.json",
- // // RawContent.ToJson());
- // // Console.WriteLine($"Saved to unknown_state_events/{Type}/{stateEventResponse.EventId}.json");
- // // }
- // // }
- //
- // return type;
- // }
- // }
+ // [field: AllowNull, MaybeNull]
+ [NotNull]
+ public JsonObject? RawContent { get; set; }
//debug
[JsonIgnore]
@@ -143,6 +119,18 @@ public class StateEvent {
[JsonIgnore]
public string InternalContentTypeName => TypedContent?.GetType().Name ?? "null";
+
+ public static bool TypeKeyPairMatches(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey;
+ public static bool Equals(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey && x.EventId == y.EventId;
+
+ /// <summary>
+ /// Compares two state events for deep equality, including type, state key, and raw content.
+ /// If you trust the server, use Equals instead, as that compares by event ID instead of raw content.
+ /// </summary>
+ /// <param name="x"></param>
+ /// <param name="y"></param>
+ /// <returns></returns>
+ public static bool DeepEquals(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey && JsonNode.DeepEquals(x.RawContent, y.RawContent);
}
public class StateEventResponse : StateEvent {
@@ -156,13 +144,13 @@ public class StateEventResponse : StateEvent {
public string? Sender { get; set; }
[JsonPropertyName("unsigned")]
- public UnsignedData? Unsigned { get; set; }
+ public JsonObject? Unsigned { get; set; }
[JsonPropertyName("event_id")]
public string? EventId { get; set; }
public class UnsignedData {
- [JsonPropertyName("age")]
+ [JsonPropertyName("age"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public ulong? Age { get; set; }
[JsonPropertyName("redacted_because")]
@@ -254,4 +242,23 @@ public class StateEventContentPolymorphicTypeInfoResolver : DefaultJsonTypeInfoR
}
*/
+/*
+public class ForgivingObjectConverter<T> : JsonConverter<T> where T : new() {
+ public override T? Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) {
+ try {
+ var text = JsonDocument.ParseValue(ref reader).RootElement.GetRawText();
+ return JsonSerializer.Deserialize<T>(text, options);
+ }
+ catch (JsonException ex) {
+ Console.WriteLine(ex);
+ return null;
+ }
+ }
+
+ public override bool CanConvert(Type typeToConvert) => true;
+
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ => JsonSerializer.Serialize<T>(writer, value, options);
+}*/
+
#endregion
\ No newline at end of file
diff --git a/LibMatrix/Utilities/CommonSyncFilters.cs b/LibMatrix/Utilities/CommonSyncFilters.cs
index bf8b987..503cc1f 100644
--- a/LibMatrix/Utilities/CommonSyncFilters.cs
+++ b/LibMatrix/Utilities/CommonSyncFilters.cs
@@ -1,7 +1,7 @@
using System.Collections.Frozen;
using LibMatrix.EventTypes.Common;
-using LibMatrix.EventTypes.Spec.State;
using LibMatrix.EventTypes.Spec.State.RoomInfo;
+using LibMatrix.EventTypes.Spec.State.Space;
using LibMatrix.Filters;
namespace LibMatrix.Utilities;
|